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 }