diff --git a/client/components/tooltip/index.jsx b/client/components/tooltip/index.jsx
index a7ce542a90c13..e0049ca665c4f 100644
--- a/client/components/tooltip/index.jsx
+++ b/client/components/tooltip/index.jsx
@@ -4,7 +4,7 @@
* External dependencies
*/
-import React, { Component } from 'react';
+import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
@@ -12,61 +12,55 @@ import classnames from 'classnames';
* Internal dependencies
*/
import Popover from 'components/popover';
-import { isMobile } from 'lib/viewport';
+import { useMobileBreakpoint } from 'lib/viewport/react';
-/**
- * Module variables
- */
-const noop = () => {};
+function Tooltip( props ) {
+ const isMobile = useMobileBreakpoint();
-class Tooltip extends Component {
- static propTypes = {
- autoPosition: PropTypes.bool,
- className: PropTypes.string,
- id: PropTypes.string,
- isVisible: PropTypes.bool,
- position: PropTypes.string,
- rootClassName: PropTypes.string,
- status: PropTypes.string,
- showDelay: PropTypes.number,
- showOnMobile: PropTypes.bool,
- };
+ if ( ! props.showOnMobile && isMobile ) {
+ return null;
+ }
- static defaultProps = {
- showDelay: 100,
- position: 'top',
- showOnMobile: false,
- };
+ const classes = classnames(
+ 'popover',
+ 'tooltip',
+ `is-${ props.status }`,
+ `is-${ props.position }`,
+ props.className
+ );
- render() {
- if ( ! this.props.showOnMobile && isMobile() ) {
- return null;
- }
+ return (
+
+ { props.children }
+
+ );
+}
- const classes = classnames(
- 'popover',
- 'tooltip',
- `is-${ this.props.status }`,
- `is-${ this.props.position }`,
- this.props.className
- );
+Tooltip.propTypes = {
+ autoPosition: PropTypes.bool,
+ className: PropTypes.string,
+ id: PropTypes.string,
+ isVisible: PropTypes.bool,
+ position: PropTypes.string,
+ rootClassName: PropTypes.string,
+ status: PropTypes.string,
+ showDelay: PropTypes.number,
+ showOnMobile: PropTypes.bool,
+};
- return (
-
- { this.props.children }
-
- );
- }
-}
+Tooltip.defaultProps = {
+ showDelay: 100,
+ position: 'top',
+ showOnMobile: false,
+};
export default Tooltip;
diff --git a/client/layout/guided-tours/docs/examples/selectors/has-selected-site-premium-or-business-plan.js b/client/layout/guided-tours/docs/examples/selectors/has-selected-site-premium-or-business-plan.js
index bbd442621b118..da97b8008609c 100644
--- a/client/layout/guided-tours/docs/examples/selectors/has-selected-site-premium-or-business-plan.js
+++ b/client/layout/guided-tours/docs/examples/selectors/has-selected-site-premium-or-business-plan.js
@@ -12,8 +12,6 @@ import { PLAN_PREMIUM, PLAN_BUSINESS } from 'lib/plans/constants';
import { getSitePlan } from 'state/sites/selectors';
import { getSelectedSiteId } from 'state/ui/selectors';
-import { isDesktop } from 'lib/viewport';
-
// NOTE: selector moved here because tour is no longer active and serves as example only
// to use in a tour, move back to 'state/ui/guided-tours/contexts' (see commented out import above)
/**
diff --git a/client/lib/viewport/README.md b/client/lib/viewport/README.md
index 84a0e84861f60..7b7228a46a040 100644
--- a/client/lib/viewport/README.md
+++ b/client/lib/viewport/README.md
@@ -8,11 +8,11 @@ This module contains functions to identify the current viewport. This can be use
Simple usage:
```js
-import { isDesktop, isMobile } from 'viewport';
+import { isDesktop, isMobile } from 'lib/viewport';
if ( isDesktop() ) {
// Render a component optimized for desktop view
-} else ( isMobile() ) {
+} else if ( isMobile() ) {
// Render a component optimized for mobile view
}
```
@@ -20,7 +20,7 @@ if ( isDesktop() ) {
Using one of the other breakpoints:
```js
-import { isWithinBreakpoint } from 'viewport';
+import { isWithinBreakpoint } from 'lib/viewport';
if ( isWithinBreakpoint( '>1400px' ) ) {
// Render a component optimized for a very large screen
@@ -32,7 +32,7 @@ if ( isWithinBreakpoint( '>1400px' ) ) {
Registering to listen to changes:
```js
-import { addIsDesktopListener, removeIsDesktopListener } from 'viewport';
+import { subscribeIsDesktop } from 'lib/viewport';
class MyComponent extends React.Component {
sizeChanged = matches => {
@@ -42,28 +42,65 @@ class MyComponent extends React.Component {
};
componentDidMount() {
- addIsDesktopListener( this.sizeChanged );
+ this.unsubscribe = subscribeIsDesktop( this.sizeChanged );
}
componentWillUnmount() {
- removeIsDesktopListener( this.sizeChanged );
+ this.unsubscribe();
}
}
```
+It also comes with React helpers that help in registering a component to breakpoint changes.
+
+Using a hook:
+
+```js
+import { useMobileBreakpoint } from 'lib/viewport/react';
+
+export default function MyComponent( props ) {
+ const isMobile = useMobileBreakpoint();
+ return
Screen size: { isMobile ? 'mobile' : 'not mobile' }
;
+}
+```
+
+Using a higher-order component:
+
+```js
+import { withMobileBreakpoint } from 'lib/viewport/react';
+
+class MyComponent extends React.Component {
+ render() {
+ const { isBreakpointActive: isMobile } = this.props;
+ return Screen size: { isMobile ? 'mobile' : 'not mobile' }
;
+ }
+}
+
+export default withMobileBreakpoint( MyComponent );
+```
+
### Supported methods
-- `isWithinBreakpoint( breakpoint )`: Whether the current screen size matches the breakpoint
-- `isMobile()`: Whether the current screen size matches a mobile breakpoint (<480px)
-- `isDesktop()`: Whether the current screen size matches a desktop breakpoint (>960px)
-- `addWithinBreakpointListener( breakpoint, listener )`: Register a listener for size changes that affect the breakpoint
-- `removeWithinBreakpointListener( breakpoint, listener )`: Unregister a previously registered listener
-- `addIsMobileListener( breakpoint, listener )`: Register a listener for size changes that affect the mobile breakpoint (<480px)
-- `removeIsMobileListener( breakpoint, listener )`: Unregister a previously registered listener
-- `addIsDesktopListener( breakpoint, listener )`: Register a listener for size changes that affect the desktop breakpoint (>960px)
-- `removeIsDesktopListener( breakpoint, listener )`: Unregister a previously registered listener
+- `isWithinBreakpoint( breakpoint )`: Whether the current screen size matches the breakpoint.
+- `isMobile()`: Whether the current screen size matches a mobile breakpoint (<480px).
+- `isDesktop()`: Whether the current screen size matches a desktop breakpoint (>960px).
+- `subscribeIsWithinBreakpoint( breakpoint, listener )`: Register a listener for size changes that affect the breakpoint. Returns the unsubscribe function.
+- `subscribeIsMobile( listener )`: Register a listener for size changes that affect the mobile breakpoint (<480px). Returns the unsubscribe function.
+- `subscribeIsDesktop( listener )`: Register a listener for size changes that affect the desktop breakpoint (>960px). Returns the unsubscribe function.
- `getWindowInnerWidth()`: Get the inner width for the browser window. **Warning**: This method triggers a layout recalc, potentially resulting in performance issues. Please use a breakpoint instead wherever possible.
+### Supported hooks
+
+- `useBreakpoint( breakpoint )`: Returns the current status for a breakpoint, and keeps it updated.
+- `useMobileBreakpoint()`: Returns the current status for the mobile breakpoint, and keeps it updated.
+- `useDesktopBreakpoint()`: Returns the current status for the desktop breakpoint, and keeps it updated.
+
+### Supported higher-order components
+
+- `withBreakpoint( breakpoint )( WrappedComponent )`: Returns a wrapped component with the current status for a breakpoint as the `isBreakpointActive` prop.
+- `withMobileBreakpoint( WrappedComponent )`: Returns a wrapped component with the current status for the mobile breakpoint as the `isBreakpointActive` prop.
+- `withDesktopBreakpoint( WrappedComponent )`: Returns a wrapped component with the current status for the desktop breakpoint as the `isBreakpointActive` prop.
+
### Supported breakpoints
- '<480px'
diff --git a/client/lib/viewport/index.js b/client/lib/viewport/index.js
index 8f7f4976ad928..cfa0caebf46e8 100644
--- a/client/lib/viewport/index.js
+++ b/client/lib/viewport/index.js
@@ -41,11 +41,13 @@
// use 769, which is just above the general maximum mobile screen width.
const SERVER_WIDTH = 769;
-const MOBILE_BREAKPOINT = '<480px';
-const DESKTOP_BREAKPOINT = '>960px';
+export const MOBILE_BREAKPOINT = '<480px';
+export const DESKTOP_BREAKPOINT = '>960px';
const isServer = typeof window === 'undefined' || ! window.matchMedia;
+const noop = () => null;
+
function createMediaQueryList( { min, max } = {} ) {
if ( min !== undefined && max !== undefined ) {
return isServer
@@ -100,51 +102,84 @@ function getMediaQueryList( breakpoint ) {
return mediaQueryLists[ breakpoint ];
}
+/**
+ * Returns whether the current window width matches a breakpoint.
+ * @param {String} breakpoint The breakpoint to consider.
+ *
+ * @returns {Boolean} Whether the provided breakpoint is matched.
+ */
export function isWithinBreakpoint( breakpoint ) {
const mediaQueryList = getMediaQueryList( breakpoint );
return mediaQueryList ? mediaQueryList.matches : undefined;
}
-export function addWithinBreakpointListener( breakpoint, listener ) {
- const mediaQueryList = getMediaQueryList( breakpoint );
-
- if ( mediaQueryList && ! isServer ) {
- mediaQueryList.addListener( evt => listener( evt.matches ) );
+/**
+ * Registers a listener to be notified of changes to breakpoint matching status.
+ * @param {String} breakpoint The breakpoint to consider.
+ * @param {Function} listener The listener to be called on change.
+ *
+ * @returns {Function} The function to be called when unsubscribing.
+ */
+export function subscribeIsWithinBreakpoint( breakpoint, listener ) {
+ if ( ! listener ) {
+ return noop;
}
-}
-export function removeWithinBreakpointListener( breakpoint, listener ) {
const mediaQueryList = getMediaQueryList( breakpoint );
if ( mediaQueryList && ! isServer ) {
- mediaQueryList.removeListener( listener );
+ const wrappedListener = evt => listener( evt.matches );
+ mediaQueryList.addListener( wrappedListener );
+ // Return unsubscribe function.
+ return () => mediaQueryList.removeListener( wrappedListener );
}
+
+ return noop;
}
+/**
+ * Returns whether the current window width matches the mobile breakpoint.
+ *
+ * @returns {Boolean} Whether the mobile breakpoint is matched.
+ */
export function isMobile() {
return isWithinBreakpoint( MOBILE_BREAKPOINT );
}
-export function addIsMobileListener( listener ) {
- return addWithinBreakpointListener( MOBILE_BREAKPOINT, listener );
-}
-
-export function removeIsMobileListener( listener ) {
- return removeWithinBreakpointListener( MOBILE_BREAKPOINT, listener );
+/**
+ * Registers a listener to be notified of changes to mobile breakpoint matching status.
+ * @param {Function} listener The listener to be called on change.
+ *
+ * @returns {Function} The registered subscription; undefined if none.
+ */
+export function subscribeIsMobile( listener ) {
+ return subscribeIsWithinBreakpoint( MOBILE_BREAKPOINT, listener );
}
+/**
+ * Returns whether the current window width matches the desktop breakpoint.
+ *
+ * @returns {Boolean} Whether the desktop breakpoint is matched.
+ */
export function isDesktop() {
return isWithinBreakpoint( DESKTOP_BREAKPOINT );
}
-export function addIsDesktopListener( listener ) {
- return addWithinBreakpointListener( DESKTOP_BREAKPOINT, listener );
-}
-
-export function removeIsDesktopListener( listener ) {
- return removeWithinBreakpointListener( DESKTOP_BREAKPOINT, listener );
+/**
+ * Registers a listener to be notified of changes to desktop breakpoint matching status.
+ * @param {Function} listener The listener to be called on change.
+ *
+ * @returns {Function} The registered subscription; undefined if none.
+ */
+export function subscribeIsDesktop( listener ) {
+ return subscribeIsWithinBreakpoint( DESKTOP_BREAKPOINT, listener );
}
+/**
+ * Returns the current window width.
+ * Avoid using this method, as it triggers a layout recalc.
+ * @returns {Number} The current window width, in pixels.
+ */
export function getWindowInnerWidth() {
return isServer ? SERVER_WIDTH : window.innerWidth;
}
diff --git a/client/lib/viewport/react.js b/client/lib/viewport/react.js
new file mode 100644
index 0000000000000..dbc9a44e018ae
--- /dev/null
+++ b/client/lib/viewport/react.js
@@ -0,0 +1,118 @@
+/** @format */
+/**
+ * External dependencies
+ */
+import React, { useState, useEffect } from 'react';
+import { createHigherOrderComponent } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import {
+ isWithinBreakpoint,
+ subscribeIsWithinBreakpoint,
+ MOBILE_BREAKPOINT,
+ DESKTOP_BREAKPOINT,
+} from '.';
+
+/**
+ * React hook for getting the status for a breakpoint and keeping it updated.
+ *
+ * @param {String} breakpoint The breakpoint to consider.
+ *
+ * @returns {Boolean} The current status for the breakpoint.
+ */
+export function useBreakpoint( breakpoint ) {
+ const [ state, setState ] = useState( () => ( {
+ isActive: isWithinBreakpoint( breakpoint ),
+ breakpoint,
+ } ) );
+
+ useEffect(() => {
+ function handleBreakpointChange( isActive ) {
+ setState( prevState => {
+ // Ensure we bail out without rendering if nothing changes, by preserving state.
+ if ( prevState.isActive === isActive && prevState.breakpoint === breakpoint ) {
+ return prevState;
+ }
+ return { isActive, breakpoint };
+ } );
+ }
+
+ const unsubscribe = subscribeIsWithinBreakpoint( breakpoint, handleBreakpointChange );
+ // The unsubscribe function is the entire cleanup for the effect.
+ return unsubscribe;
+ }, [ breakpoint ]);
+
+ return breakpoint === state.breakpoint ? state.isActive : isWithinBreakpoint( breakpoint );
+}
+
+/**
+ * React hook for getting the status for the mobile breakpoint and keeping it
+ * updated.
+ *
+ * @returns {Boolean} The current status for the breakpoint.
+ */
+export function useMobileBreakpoint() {
+ return useBreakpoint( MOBILE_BREAKPOINT );
+}
+
+/**
+ * React hook for getting the status for the desktop breakpoint and keeping it
+ * updated.
+ *
+ * @returns {Boolean} The current status for the breakpoint.
+ */
+export function useDesktopBreakpoint() {
+ return useBreakpoint( DESKTOP_BREAKPOINT );
+}
+
+/**
+ * React higher order component for getting the status for a breakpoint and
+ * keeping it updated.
+ *
+ * @param {String} breakpoint The breakpoint to consider.
+ *
+ * @returns {Function} A function that given a component returns the
+ * wrapped component.
+ */
+export const withBreakpoint = breakpoint =>
+ createHigherOrderComponent(
+ WrappedComponent => props => {
+ const isBreakpointActive = useBreakpoint( breakpoint );
+ return ;
+ },
+ 'WithBreakpoint'
+ );
+
+/**
+ * React higher order component for getting the status for the mobile
+ * breakpoint and keeping it updated.
+ *
+ * @param {React.Component|Function} Wrapped The component to wrap.
+ *
+ * @returns {Function} The wrapped component.
+ */
+export const withMobileBreakpoint = createHigherOrderComponent(
+ WrappedComponent => props => {
+ const isBreakpointActive = useBreakpoint( MOBILE_BREAKPOINT );
+ return ;
+ },
+ 'WithMobileBreakpoint'
+);
+
+/**
+ * React higher order component for getting the status for the desktop
+ * breakpoint and keeping it updated.
+ *
+ * @param {React.Component|Function} Wrapped The component to wrap.
+ *
+ * @returns {Function} The wrapped component.
+ */
+export const withDesktopBreakpoint = createHigherOrderComponent(
+ WrappedComponent => props => {
+ const isBreakpointActive = useBreakpoint( DESKTOP_BREAKPOINT );
+ return ;
+ },
+ 'WithDesktopBreakpoint'
+);
diff --git a/client/lib/viewport/test/index.js b/client/lib/viewport/test/index.js
new file mode 100644
index 0000000000000..0f05a1e5f0097
--- /dev/null
+++ b/client/lib/viewport/test/index.js
@@ -0,0 +1,180 @@
+/**
+ * @format
+ * @jest-environment jsdom
+ */
+
+/**
+ * Internal dependencies
+ */
+let viewport;
+
+const matchesMock = jest.fn( () => 'foo' );
+const addListenerMock = jest.fn();
+const removeListenerMock = jest.fn();
+
+const matchMediaMock = jest.fn( query => {
+ const mediaListObjectMock = {
+ addListener: listener => addListenerMock( query, listener ),
+ removeListener: listener => removeListenerMock( query, listener ),
+ };
+ // Add matches read-only property.
+ Object.defineProperty( mediaListObjectMock, 'matches', {
+ get: () => matchesMock( query ),
+ } );
+ return mediaListObjectMock;
+} );
+
+describe( 'viewport', () => {
+ beforeAll( async () => {
+ window.matchMedia = matchMediaMock;
+ viewport = await import( '..' );
+ // Disable console warnings.
+ jest.spyOn( console, 'warn' ).mockImplementation( () => '' );
+ } );
+
+ beforeEach( () => {
+ matchesMock.mockClear();
+ addListenerMock.mockClear();
+ removeListenerMock.mockClear();
+ } );
+
+ afterAll( () => {
+ jest.restoreAllMocks();
+ } );
+
+ describe( 'isWithinBreakpoint', () => {
+ test( 'should return undefined when called with no breakpoint', () => {
+ expect( viewport.isWithinBreakpoint() ).toBe( undefined );
+ } );
+
+ test( 'should return undefined for an unknown breakpoint', () => {
+ expect( viewport.isWithinBreakpoint( 'unknown' ) ).toBe( undefined );
+ } );
+
+ test( 'should retrieve the current status for a known breakpoint', () => {
+ expect( viewport.isWithinBreakpoint( '<960px' ) ).toBe( 'foo' );
+ expect( matchesMock ).toHaveBeenCalledTimes( 1 );
+ expect( matchesMock ).toHaveBeenCalledWith( '(max-width: 960px)' );
+ } );
+ } );
+
+ describe( 'isMobile', () => {
+ test( 'should retrieve the current status for the mobile breakpoint', () => {
+ expect( viewport.isMobile() ).toBe( 'foo' );
+ expect( matchesMock ).toHaveBeenCalledTimes( 1 );
+ expect( matchesMock ).toHaveBeenCalledWith( '(max-width: 480px)' );
+ } );
+ } );
+
+ describe( 'isDesktop', () => {
+ test( 'should retrieve the current status for the desktop breakpoint', () => {
+ expect( viewport.isDesktop() ).toBe( 'foo' );
+ expect( matchesMock ).toHaveBeenCalledTimes( 1 );
+ expect( matchesMock ).toHaveBeenCalledWith( '(min-width: 961px)' );
+ } );
+ } );
+
+ describe( 'subscribeIsWithinBreakpoint', () => {
+ test( 'should do nothing if nothing is provided', () => {
+ let unsubscribe;
+ expect( () => ( unsubscribe = viewport.subscribeIsWithinBreakpoint() ) ).not.toThrow();
+ expect( () => unsubscribe() ).not.toThrow();
+ expect( addListenerMock ).not.toHaveBeenCalled();
+ } );
+ test( 'should do nothing if an invalid breakpoint is provided', () => {
+ let unsubscribe;
+ expect(
+ () => ( unsubscribe = viewport.subscribeIsWithinBreakpoint( 'unknown', () => '' ) )
+ ).not.toThrow();
+ expect( () => unsubscribe() ).not.toThrow();
+ expect( addListenerMock ).not.toHaveBeenCalled();
+ } );
+ test( 'should do nothing if no listener is provided', () => {
+ let unsubscribe;
+ expect(
+ () => ( unsubscribe = viewport.subscribeIsWithinBreakpoint( '<960px' ) )
+ ).not.toThrow();
+ expect( () => unsubscribe() ).not.toThrow();
+ expect( addListenerMock ).not.toHaveBeenCalled();
+ } );
+ test( 'should add a listener for a valid breakpoint', () => {
+ const listener = jest.fn();
+ const event = { matches: 'bar' };
+ let unsubscribe;
+ expect(
+ () => ( unsubscribe = viewport.subscribeIsWithinBreakpoint( '<960px', listener ) )
+ ).not.toThrow();
+ expect( addListenerMock ).toHaveBeenCalledTimes( 1 );
+ expect( addListenerMock ).toHaveBeenCalledWith(
+ '(max-width: 960px)',
+ expect.any( Function )
+ );
+ // Call registered listener to make sure it got added correctly.
+ const registeredListener =
+ addListenerMock.mock.calls[ addListenerMock.mock.calls.length - 1 ][ 1 ];
+ registeredListener( event );
+ expect( listener ).toHaveBeenCalledTimes( 1 );
+ expect( listener ).toHaveBeenCalledWith( 'bar' );
+ // Clean up.
+ expect( () => unsubscribe() ).not.toThrow();
+ expect( removeListenerMock ).toHaveBeenCalledTimes( 1 );
+ expect( removeListenerMock ).toHaveBeenCalledWith( '(max-width: 960px)', registeredListener );
+ } );
+ } );
+
+ describe( 'subscribeIsMobile', () => {
+ test( 'should do nothing if nothing is provided', () => {
+ expect( () => viewport.subscribeIsMobile() ).not.toThrow();
+ expect( addListenerMock ).not.toHaveBeenCalled();
+ } );
+ test( 'should add a listener', () => {
+ const listener = jest.fn();
+ const event = { matches: 'bar' };
+ let unsubscribe;
+ expect( () => ( unsubscribe = viewport.subscribeIsMobile( listener ) ) ).not.toThrow();
+ expect( addListenerMock ).toHaveBeenCalledTimes( 1 );
+ expect( addListenerMock ).toHaveBeenCalledWith(
+ '(max-width: 480px)',
+ expect.any( Function )
+ );
+ // Call registered listener to make sure it got added correctly.
+ const registeredListener =
+ addListenerMock.mock.calls[ addListenerMock.mock.calls.length - 1 ][ 1 ];
+ registeredListener( event );
+ expect( listener ).toHaveBeenCalledTimes( 1 );
+ expect( listener ).toHaveBeenCalledWith( 'bar' );
+ // Clean up.
+ expect( () => unsubscribe() ).not.toThrow();
+ expect( removeListenerMock ).toHaveBeenCalledTimes( 1 );
+ expect( removeListenerMock ).toHaveBeenCalledWith( '(max-width: 480px)', registeredListener );
+ } );
+ } );
+
+ describe( 'subscribeIsDesktop', () => {
+ test( 'should do nothing if nothing is provided', () => {
+ expect( () => viewport.subscribeIsDesktop() ).not.toThrow();
+ expect( addListenerMock ).not.toHaveBeenCalled();
+ } );
+ test( 'should add a listener', () => {
+ const listener = jest.fn();
+ const event = { matches: 'bar' };
+ let unsubscribe;
+ expect( () => ( unsubscribe = viewport.subscribeIsDesktop( listener ) ) ).not.toThrow();
+ expect( addListenerMock ).toHaveBeenCalledTimes( 1 );
+ expect( addListenerMock ).toHaveBeenCalledWith(
+ '(min-width: 961px)',
+ expect.any( Function )
+ );
+ // Call registered listener to make sure it got added correctly.
+ const registeredListener =
+ addListenerMock.mock.calls[ addListenerMock.mock.calls.length - 1 ][ 1 ];
+ registeredListener( event );
+ expect( listener ).toHaveBeenCalledTimes( 1 );
+ expect( listener ).toHaveBeenCalledWith( 'bar' );
+ // Clean up.
+ expect( () => unsubscribe() ).not.toThrow();
+ expect( removeListenerMock ).toHaveBeenCalledTimes( 1 );
+ expect( removeListenerMock ).toHaveBeenCalledWith( '(min-width: 961px)', registeredListener );
+ } );
+ } );
+} );
diff --git a/client/lib/viewport/test/react.js b/client/lib/viewport/test/react.js
new file mode 100644
index 0000000000000..49a2c50f12136
--- /dev/null
+++ b/client/lib/viewport/test/react.js
@@ -0,0 +1,276 @@
+/**
+ * @format
+ * @jest-environment jsdom
+ */
+
+/**
+ * Internal dependencies
+ */
+import React, { useState, useEffect } from 'react';
+import ReactDOM from 'react-dom';
+import { act } from 'react-dom/test-utils';
+
+let helpers;
+
+const listeners = {};
+
+const matchesMock = jest.fn( () => true );
+const addListenerMock = jest.fn( ( query, listener ) => {
+ if ( listeners[ query ] ) {
+ listeners[ query ].push( listener );
+ } else {
+ listeners[ query ] = [ listener ];
+ }
+} );
+const removeListenerMock = jest.fn( ( query, listener ) => {
+ if ( listeners[ query ] ) {
+ listeners[ query ] = listeners[ query ].filter( item => item !== listener );
+ }
+} );
+
+function callQueryListeners( query, value ) {
+ for ( const listener of listeners[ query ] ) {
+ listener( { matches: value } );
+ }
+}
+
+const matchMediaMock = jest.fn( query => {
+ const mediaListObjectMock = {
+ addListener: listener => addListenerMock( query, listener ),
+ removeListener: listener => removeListenerMock( query, listener ),
+ };
+ // Add matches read-only property.
+ Object.defineProperty( mediaListObjectMock, 'matches', {
+ get: () => matchesMock( query ),
+ } );
+ return mediaListObjectMock;
+} );
+
+describe( 'viewport/react', () => {
+ let container;
+
+ // Auxiliary method to test a valid component.
+ function runComponentTests( TestComponent, query ) {
+ // Test initial state (defaults to true).
+ act( () => {
+ ReactDOM.render(
+
+
+
+
+
,
+ container
+ );
+ } );
+
+ expect( container.textContent ).toBe( 'truetruetrue' );
+ expect( listeners[ query ] ).not.toBe( undefined );
+ expect( listeners[ query ].length ).toBe( 3 );
+
+ // Simulate a window resize by calling the registered listeners for a query
+ // with a different value (false).
+ act( () => {
+ callQueryListeners( query, false );
+ } );
+
+ expect( container.textContent ).toBe( 'falsefalsefalse' );
+
+ // Ensure that listeners are cleaned up when the component unmounts.
+ act( () => {
+ ReactDOM.render( , container );
+ } );
+
+ expect( listeners[ query ].length ).toBe( 0 );
+ }
+
+ // Auxiliary class for HOC tests.
+ class BaseComponent extends React.Component {
+ render() {
+ const isActive = this.props.isBreakpointActive;
+ return isActive ? 'true' : 'false';
+ }
+ }
+
+ beforeAll( async () => {
+ window.matchMedia = matchMediaMock;
+ helpers = await import( '../react' );
+ // Disable console warnings.
+ jest.spyOn( console, 'warn' ).mockImplementation( () => '' );
+ } );
+
+ beforeEach( () => {
+ container = document.createElement( 'div' );
+ document.body.appendChild( container );
+
+ matchesMock.mockClear();
+ addListenerMock.mockClear();
+ removeListenerMock.mockClear();
+ } );
+
+ afterEach( () => {
+ document.body.removeChild( container );
+ ReactDOM.unmountComponentAtNode( container );
+ container = null;
+ } );
+
+ afterAll( () => {
+ jest.restoreAllMocks();
+ } );
+
+ describe( 'useBreakpoint', () => {
+ test( 'returns undefined when called with no breakpoint', () => {
+ function TestComponent() {
+ const isActive = helpers.useBreakpoint();
+ return isActive === undefined ? 'undefined' : `unexpected value: ${ isActive }`;
+ }
+
+ act( () => {
+ ReactDOM.render( , container );
+ } );
+
+ expect( container.textContent ).toBe( 'undefined' );
+ } );
+
+ test( 'returns undefined for an unknown breakpoint', () => {
+ function TestComponent() {
+ const isActive = helpers.useBreakpoint( 'unknown' );
+ return isActive === undefined ? 'undefined' : `unexpected value: ${ isActive }`;
+ }
+
+ act( () => {
+ ReactDOM.render( , container );
+ } );
+
+ expect( container.textContent ).toBe( 'undefined' );
+ } );
+
+ test( 'returns the current breakpoint state for a valid breakpoint', () => {
+ function TestComponent() {
+ const isActive = helpers.useBreakpoint( '<960px' );
+ return isActive ? 'true' : 'false';
+ }
+
+ runComponentTests( TestComponent, '(max-width: 960px)' );
+ } );
+
+ test( 'correctly updates if the breakpoint changes', () => {
+ let callback;
+
+ function TestComponent() {
+ const [ query, setQuery ] = useState( '<960px' );
+ const isActive = helpers.useBreakpoint( query );
+ const changeQuery = () => setQuery( '<480px' );
+
+ useEffect( () => {
+ callback = changeQuery;
+ } );
+
+ return isActive ? 'true' : 'false';
+ }
+
+ // Test initial state (defaults to true).
+ act( () => {
+ ReactDOM.render(
+
+
+
,
+ container
+ );
+ } );
+
+ expect( container.textContent ).toBe( 'true' );
+
+ // Change to false.
+ act( () => {
+ callQueryListeners( '(max-width: 960px)', false );
+ } );
+
+ expect( container.textContent ).toBe( 'false' );
+
+ // Change breakpoint, defaulting back to true.
+ act( () => {
+ callback();
+ } );
+ expect( container.textContent ).toBe( 'true' );
+ expect( listeners[ '(max-width: 960px)' ].length ).toBe( 0 );
+ expect( listeners[ '(max-width: 480px)' ].length ).toBe( 1 );
+
+ // Ensure that listeners are cleaned up when the component unmounts.
+ act( () => {
+ ReactDOM.render( , container );
+ } );
+
+ expect( listeners[ '(max-width: 480px)' ].length ).toBe( 0 );
+ } );
+ } );
+
+ describe( 'useMobileBreakpoint', () => {
+ test( 'returns the current breakpoint state for the mobile breakpoint', () => {
+ function TestComponent() {
+ const isActive = helpers.useMobileBreakpoint();
+ return isActive ? 'true' : 'false';
+ }
+
+ runComponentTests( TestComponent, '(max-width: 480px)' );
+ } );
+ } );
+
+ describe( 'useDesktopBreakpoint', () => {
+ test( 'returns the current breakpoint state for the desktop breakpoint', () => {
+ function TestComponent() {
+ const isActive = helpers.useDesktopBreakpoint();
+ return isActive ? 'true' : 'false';
+ }
+
+ runComponentTests( TestComponent, '(min-width: 961px)' );
+ } );
+ } );
+
+ describe( 'withBreakpoint', () => {
+ class ExpectUndefinedComponent extends React.Component {
+ render() {
+ const isActive = this.props.isBreakpointActive;
+ return isActive === undefined ? 'undefined' : `unexpected value: ${ isActive }`;
+ }
+ }
+
+ test( 'returns undefined when called with no breakpoint', () => {
+ const TestComponent = helpers.withBreakpoint()( ExpectUndefinedComponent );
+
+ act( () => {
+ ReactDOM.render( , container );
+ } );
+
+ expect( container.textContent ).toBe( 'undefined' );
+ } );
+
+ test( 'returns undefined for an unknown breakpoint', () => {
+ const TestComponent = helpers.withBreakpoint( 'unknown' )( ExpectUndefinedComponent );
+
+ act( () => {
+ ReactDOM.render( , container );
+ } );
+
+ expect( container.textContent ).toBe( 'undefined' );
+ } );
+
+ test( 'returns the current breakpoint state for a valid breakpoint', () => {
+ const TestComponent = helpers.withBreakpoint( '<960px' )( BaseComponent );
+ runComponentTests( TestComponent, '(max-width: 960px)' );
+ } );
+ } );
+
+ describe( 'withMobileBreakpoint', () => {
+ test( 'returns the current breakpoint state for the mobile breakpoint', () => {
+ const TestComponent = helpers.withMobileBreakpoint( BaseComponent );
+ runComponentTests( TestComponent, '(max-width: 480px)' );
+ } );
+ } );
+
+ describe( 'withDesktopBreakpoint', () => {
+ test( 'returns the current breakpoint state for the desktop breakpoint', () => {
+ const TestComponent = helpers.withDesktopBreakpoint( BaseComponent );
+ runComponentTests( TestComponent, '(min-width: 961px)' );
+ } );
+ } );
+} );
diff --git a/client/my-sites/activity/activity-log-item/aggregated.jsx b/client/my-sites/activity/activity-log-item/aggregated.jsx
index 7648f9544b9f2..7e7b2541d70cf 100644
--- a/client/my-sites/activity/activity-log-item/aggregated.jsx
+++ b/client/my-sites/activity/activity-log-item/aggregated.jsx
@@ -25,7 +25,7 @@ import { addQueryArgs } from 'lib/url';
import ActivityActor from './activity-actor';
import ActivityMedia from './activity-media';
import analytics from 'lib/analytics';
-import { isDesktop, addIsDesktopListener, removeIsDesktopListener } from 'lib/viewport';
+import { withDesktopBreakpoint } from 'lib/viewport/react';
const MAX_STREAM_ITEMS_IN_AGGREGATE = 10;
@@ -71,20 +71,8 @@ class ActivityLogAggregatedItem extends Component {
this.trackClick( 'view_all' );
};
- sizeChanged = () => {
- this.forceUpdate();
- };
-
- componentDidMount() {
- addIsDesktopListener( this.sizeChanged );
- }
-
- componentWillUnmount() {
- removeIsDesktopListener( this.sizeChanged );
- }
-
renderHeader() {
- const { activity } = this.props;
+ const { activity, isBreakpointActive: isDesktop } = this.props;
const {
actorAvatarUrl,
actorName,
@@ -93,7 +81,6 @@ class ActivityLogAggregatedItem extends Component {
multipleActors,
activityMedia,
} = activity;
- const isDesktopSize = isDesktop();
let actor;
if ( multipleActors ) {
actor = ;
@@ -104,7 +91,7 @@ class ActivityLogAggregatedItem extends Component {
return (
{ actor }
- { activityMedia && isDesktopSize && (
+ { activityMedia && isDesktop && (
- { activityMedia && ! isDesktopSize && (
+ { activityMedia && ! isDesktop && (
{
export default connect(
mapStateToProps,
null
-)( localize( ActivityLogAggregatedItem ) );
+)( withDesktopBreakpoint( localize( ActivityLogAggregatedItem ) ) );
diff --git a/client/my-sites/activity/activity-log-item/index.jsx b/client/my-sites/activity/activity-log-item/index.jsx
index 5593a19451a2f..22fe09be81f01 100644
--- a/client/my-sites/activity/activity-log-item/index.jsx
+++ b/client/my-sites/activity/activity-log-item/index.jsx
@@ -40,7 +40,7 @@ import getSiteGmtOffset from 'state/selectors/get-site-gmt-offset';
import getSiteTimezoneValue from 'state/selectors/get-site-timezone-value';
import { adjustMoment } from '../activity-log/utils';
import { getSite } from 'state/sites/selectors';
-import { isDesktop, addIsDesktopListener, removeIsDesktopListener } from 'lib/viewport';
+import { withDesktopBreakpoint } from 'lib/viewport/react';
class ActivityLogItem extends Component {
static propTypes = {
@@ -96,23 +96,22 @@ class ActivityLogItem extends Component {
this.forceUpdate();
};
- componentDidMount() {
- addIsDesktopListener( this.sizeChanged );
- }
-
- componentWillUnmount() {
- removeIsDesktopListener( this.sizeChanged );
- }
-
renderHeader() {
const {
- activity: { activityTitle, actorAvatarUrl, actorName, actorRole, actorType, activityMedia },
+ activity: {
+ activityTitle,
+ actorAvatarUrl,
+ actorName,
+ actorRole,
+ actorType,
+ activityMedia,
+ isBreakpointActive: isDesktop,
+ },
} = this.props;
- const isDesktopSize = isDesktop();
return (
- { activityMedia && isDesktopSize && (
+ { activityMedia && isDesktop && (
{ activityTitle }
- { activityMedia && ! isDesktopSize && (
+ { activityMedia && ! isDesktop && (
export default connect(
mapStateToProps,
mapDispatchToProps
-)( localize( ActivityLogItem ) );
+)( withDesktopBreakpoint( localize( ActivityLogItem ) ) );
diff --git a/client/my-sites/plan-features/item.jsx b/client/my-sites/plan-features/item.jsx
index d90527c0d9068..5979c1c1847cc 100644
--- a/client/my-sites/plan-features/item.jsx
+++ b/client/my-sites/plan-features/item.jsx
@@ -11,9 +11,11 @@ import Gridicon from 'gridicons';
* Internal dependencies
*/
import InfoPopover from 'components/info-popover';
-import { isMobile } from 'lib/viewport';
+import { useMobileBreakpoint } from 'lib/viewport/react';
export default function PlanFeaturesItem( { children, description, hideInfoPopover } ) {
+ const isMobile = useMobileBreakpoint();
+
return (
@@ -21,7 +23,7 @@ export default function PlanFeaturesItem( { children, description, hideInfoPopov
{ hideInfoPopover ? null : (
{ description }