diff --git a/.eslintrc.json b/.eslintrc.json index 59fb1e9a419a2..4894ff059d4f0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -123,9 +123,13 @@ "message": "Use @wordpress/utils as import path instead." }, { - "selector": "ImportDeclaration[source.value=/^edit-poost$/]", + "selector": "ImportDeclaration[source.value=/^edit-post$/]", "message": "Use @wordpress/edit-post as import path instead." }, + { + "selector": "ImportDeclaration[source.value=/^viewport$/]", + "message": "Use @wordpress/viewport as import path instead." + }, { "selector": "CallExpression[callee.name=/^__|_n|_x$/]:not([arguments.0.type=/^Literal|BinaryExpression$/])", "message": "Translate function arguments must be string literals." diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index 126aff18582db..561d63dbe065c 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -99,7 +99,7 @@ zip -r gutenberg.zip \ blocks/library/*/*.php \ post-content.js \ $vendor_scripts \ - {blocks,components,date,editor,element,hooks,i18n,data,utils,edit-post}/build/*.{js,map} \ + {blocks,components,date,editor,element,hooks,i18n,data,utils,edit-post,viewport}/build/*.{js,map} \ {blocks,components,editor,edit-post}/build/*.css \ languages/gutenberg.pot \ README.md diff --git a/components/higher-order/if-condition/README.md b/components/higher-order/if-condition/README.md new file mode 100644 index 0000000000000..0792fc2518544 --- /dev/null +++ b/components/higher-order/if-condition/README.md @@ -0,0 +1,20 @@ +If Condition +============ + +`ifCondition` is a higher-order component creator, used for creating a new component which renders if the given condition is satisfied. + +## Usage + +`ifCondition`, passed with a predicate function, will render the underlying component only if the predicate returns a truthy value. The predicate is passed the component's own original props as an argument. + +```jsx +function MyEvenNumber( { number } ) { + // This is only reached if the `number` prop is even. Otherwise, nothing + // will be rendered. + return { number }; +} + +MyEvenNumber = ifCondition( + ( { number } ) => number % 2 === 0 +)( MyEvenNumber ); +``` diff --git a/components/higher-order/if-condition/index.js b/components/higher-order/if-condition/index.js new file mode 100644 index 0000000000000..efab3a30359ca --- /dev/null +++ b/components/higher-order/if-condition/index.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { getWrapperDisplayName } from '@wordpress/element'; + +/** + * Higher-order component creator, creating a new component which renders if + * the given condition is satisfied or with the given optional prop name. + * + * @param {Function} predicate Function to test condition. + * + * @return {Function} Higher-order component. + */ +const ifCondition = ( predicate ) => ( WrappedComponent ) => { + const EnhancedComponent = ( props ) => { + if ( ! predicate( props ) ) { + return null; + } + + return ; + }; + + EnhancedComponent.displayName = getWrapperDisplayName( WrappedComponent, 'ifCondition' ); + + return EnhancedComponent; +}; + +export default ifCondition; diff --git a/components/index.js b/components/index.js index 1f1ed1f3c32e9..0f1c40ee2d023 100644 --- a/components/index.js +++ b/components/index.js @@ -47,6 +47,7 @@ export { default as TreeSelect } from './tree-select'; export { Slot, Fill, Provider as SlotFillProvider } from './slot-fill'; // Higher-Order Components +export { default as ifCondition } from './higher-order/if-condition'; export { default as navigateRegions } from './higher-order/navigate-regions'; export { default as withAPIData } from './higher-order/with-api-data'; export { default as withContext } from './higher-order/with-context'; diff --git a/data/index.js b/data/index.js index 93fa8fa80c09a..5ce9b0ac62650 100644 --- a/data/index.js +++ b/data/index.js @@ -31,6 +31,32 @@ export function globalListener() { listeners.forEach( listener => listener() ); } +/** + * Convenience for registering reducer with actions and selectors. + * + * @param {string} reducerKey Reducer key. + * @param {Object} options Store description (reducer, actions, selectors). + * + * @return {Object} Registered store object. + */ +export function registerStore( reducerKey, options ) { + if ( ! options.reducer ) { + throw new TypeError( 'Must specify store reducer' ); + } + + const store = registerReducer( reducerKey, options.reducer ); + + if ( options.actions ) { + registerActions( reducerKey, options.actions ); + } + + if ( options.selectors ) { + registerSelectors( reducerKey, options.selectors ); + } + + return store; +} + /** * Registers a new sub-reducer to the global state and returns a Redux-like store object. * diff --git a/data/test/index.js b/data/test/index.js index 8b63e41f4e716..4dcdf9dfb67a5 100644 --- a/data/test/index.js +++ b/data/test/index.js @@ -12,6 +12,7 @@ import { compose } from '@wordpress/element'; * Internal dependencies */ import { + registerStore, registerReducer, registerSelectors, registerActions, @@ -22,7 +23,40 @@ import { subscribe, } from '../'; -describe( 'store', () => { +describe( 'registerStore', () => { + it( 'should be shorthand for reducer, actions, selectors registration', () => { + const store = registerStore( 'butcher', { + reducer( state = { ribs: 6, chicken: 4 }, action ) { + switch ( action.type ) { + case 'sale': + return { + ...state, + [ action.meat ]: state[ action.meat ] / 2, + }; + } + + return state; + }, + selectors: { + getPrice: ( state, meat ) => state[ meat ], + }, + actions: { + startSale: ( meat ) => ( { type: 'sale', meat } ), + }, + } ); + + expect( store.getState() ).toEqual( { ribs: 6, chicken: 4 } ); + expect( dispatch( 'butcher' ) ).toHaveProperty( 'startSale' ); + expect( select( 'butcher' ) ).toHaveProperty( 'getPrice' ); + expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 4 ); + expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 ); + dispatch( 'butcher' ).startSale( 'chicken' ); + expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 2 ); + expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 ); + } ); +} ); + +describe( 'registerReducer', () => { it( 'Should append reducers to the state', () => { const reducer1 = () => 'chicken'; const reducer2 = () => 'ribs'; diff --git a/edit-post/components/header/fixed-toolbar-toggle/index.js b/edit-post/components/header/fixed-toolbar-toggle/index.js index 703fba930a819..3185a885bc6c4 100644 --- a/edit-post/components/header/fixed-toolbar-toggle/index.js +++ b/edit-post/components/header/fixed-toolbar-toggle/index.js @@ -1,24 +1,17 @@ /** - * External Dependencies + * WordPress Dependencies */ -import { connect } from 'react-redux'; +import { withSelect, withDispatch } from '@wordpress/data'; /** * WordPress Dependencies */ import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/element'; import { MenuItemsGroup, MenuItemsToggle, withInstanceId } from '@wordpress/components'; +import { ifViewportMatches } from '@wordpress/viewport'; -/** - * Internal Dependencies - */ -import { hasFixedToolbar, isMobile } from '../../../store/selectors'; -import { toggleFeature } from '../../../store/actions'; - -function FeatureToggle( { onToggle, active, onMobile } ) { - if ( onMobile ) { - return null; - } +function FeatureToggle( { onToggle, isActive } ) { return ( ); } -export default connect( - ( state ) => ( { - active: hasFixedToolbar( state ), - onMobile: isMobile( state ), - } ), - ( dispatch, ownProps ) => ( { +export default compose( [ + withSelect( ( select ) => ( { + isActive: select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ), + } ) ), + withDispatch( ( dispatch, ownProps ) => ( { onToggle() { - dispatch( toggleFeature( 'fixedToolbar' ) ); + dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); ownProps.onToggle(); }, - } ), - undefined, - { storeKey: 'edit-post' } -)( withInstanceId( FeatureToggle ) ); + } ) ), + ifViewportMatches( 'medium' ), + withInstanceId, +] )( FeatureToggle ); diff --git a/edit-post/components/header/header-toolbar/index.js b/edit-post/components/header/header-toolbar/index.js index 13333bab5c71d..452656debf362 100644 --- a/edit-post/components/header/header-toolbar/index.js +++ b/edit-post/components/header/header-toolbar/index.js @@ -1,7 +1,9 @@ /** - * External dependencies + * WordPress dependencies */ -import { connect } from 'react-redux'; +import { compose } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { withViewportMatch } from '@wordpress/viewport'; /** * WordPress dependencies @@ -21,9 +23,8 @@ import { * Internal dependencies */ import './style.scss'; -import { hasFixedToolbar } from '../../../store/selectors'; -function HeaderToolbar( { fixedToolbarActive } ) { +function HeaderToolbar( { hasFixedToolbar, isLargeViewport } ) { return ( - { fixedToolbarActive && ( + { hasFixedToolbar && isLargeViewport && (
@@ -43,11 +44,9 @@ function HeaderToolbar( { fixedToolbarActive } ) { ); } -export default connect( - ( state ) => ( { - fixedToolbarActive: hasFixedToolbar( state ), - } ), - undefined, - undefined, - { storeKey: 'edit-post' } -)( HeaderToolbar ); +export default compose( [ + withSelect( ( select ) => ( { + hasFixedToolbar: select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ), + } ) ), + withViewportMatch( { isLargeViewport: 'medium' } ), +] )( HeaderToolbar ); diff --git a/edit-post/components/visual-editor/index.js b/edit-post/components/visual-editor/index.js index 2f5be08f023fb..1d621863aeb3c 100644 --- a/edit-post/components/visual-editor/index.js +++ b/edit-post/components/visual-editor/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { connect } from 'react-redux'; - /** * WordPress dependencies */ @@ -15,16 +10,17 @@ import { BlockSelectionClearer, MultiSelectScrollIntoView, } from '@wordpress/editor'; -import { Fragment } from '@wordpress/element'; +import { Fragment, compose } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { withViewportMatch } from '@wordpress/viewport'; /** * Internal dependencies */ import './style.scss'; import BlockInspectorButton from './block-inspector-button'; -import { hasFixedToolbar } from '../../store/selectors'; -function VisualEditor( props ) { +function VisualEditor( { hasFixedToolbar, isLargeViewport } ) { return ( @@ -33,7 +29,7 @@ function VisualEditor( props ) { ( @@ -46,13 +42,9 @@ function VisualEditor( props ) { ); } -export default connect( - ( state ) => { - return { - hasFixedToolbar: hasFixedToolbar( state ), - }; - }, - undefined, - undefined, - { storeKey: 'edit-post' } -)( VisualEditor ); +export default compose( [ + withSelect( ( select ) => ( { + hasFixedToolbar: select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ), + } ) ), + withViewportMatch( { isLargeViewport: 'medium' } ), +] )( VisualEditor ); diff --git a/edit-post/store/actions.js b/edit-post/store/actions.js index c635ab8f76ebe..8414c35238ded 100644 --- a/edit-post/store/actions.js +++ b/edit-post/store/actions.js @@ -89,19 +89,6 @@ export function toggleGeneralSidebarEditorPanel( panel ) { }; } -/** - * Returns an action object used in signalling that the viewport type preference should be set. - * - * @param {string} viewportType The viewport type (desktop or mobile). - * @return {Object} Action object. - */ -export function setViewportType( viewportType ) { - return { - type: 'SET_VIEWPORT_TYPE', - viewportType, - }; -} - /** * Returns an action object used to toggle a feature flag. * diff --git a/edit-post/store/constants.js b/edit-post/store/constants.js deleted file mode 100644 index d15bc20dc6971..0000000000000 --- a/edit-post/store/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Internal dependencies - */ -import breakpointsScssVariables from '!!sass-variables-loader!../assets/stylesheets/_breakpoints.scss'; - -export const BREAK_HUGE = parseInt( breakpointsScssVariables.breakHuge ); -export const BREAK_WIDE = parseInt( breakpointsScssVariables.breakWide ); -export const BREAK_LARGE = parseInt( breakpointsScssVariables.breakLarge ); -export const BREAK_MEDIUM = parseInt( breakpointsScssVariables.breakMedium ); -export const BREAK_SMALL = parseInt( breakpointsScssVariables.breakSmall ); -export const BREAK_MOBILE = parseInt( breakpointsScssVariables.breakMobile ); diff --git a/edit-post/store/defaults.js b/edit-post/store/defaults.js index bb5277c4efb79..37126bf30e613 100644 --- a/edit-post/store/defaults.js +++ b/edit-post/store/defaults.js @@ -1,6 +1,5 @@ export const PREFERENCES_DEFAULTS = { editorMode: 'visual', - viewportType: 'desktop', // 'desktop' | 'mobile' activeGeneralSidebar: 'editor', // null | 'editor' | 'plugin' activeSidebarPanel: { // The keys in this object should match activeSidebarPanel values editor: null, // 'document' | 'block' diff --git a/edit-post/store/index.js b/edit-post/store/index.js index d96869e558bbc..5cc93828edf9f 100644 --- a/edit-post/store/index.js +++ b/edit-post/store/index.js @@ -1,27 +1,47 @@ /** * WordPress Dependencies */ -import { registerReducer, withRehydratation, loadAndPersist } from '@wordpress/data'; +import { + registerStore, + withRehydratation, + loadAndPersist, + subscribe, + dispatch, + select, +} from '@wordpress/data'; /** * Internal dependencies */ import reducer from './reducer'; -import enhanceWithBrowserSize from './mobile'; -import { BREAK_MEDIUM } from './constants'; import applyMiddlewares from './middlewares'; +import * as actions from './actions'; +import * as selectors from './selectors'; /** * Module Constants */ const STORAGE_KEY = `WP_EDIT_POST_PREFERENCES_${ window.userSettings.uid }`; -const MODULE_KEY = 'core/edit-post'; -const store = applyMiddlewares( - registerReducer( MODULE_KEY, withRehydratation( reducer, 'preferences', STORAGE_KEY ) ) -); +const store = registerStore( 'core/edit-post', { + reducer: withRehydratation( reducer, 'preferences', STORAGE_KEY ), + actions, + selectors, +} ); +applyMiddlewares( store ); loadAndPersist( store, reducer, 'preferences', STORAGE_KEY ); -enhanceWithBrowserSize( store, BREAK_MEDIUM ); + +let lastIsSmall; +subscribe( () => { + const isSmall = select( 'core/viewport' ).isViewportMatch( '< medium' ); + const hasViewportShrunk = isSmall && ! lastIsSmall; + lastIsSmall = isSmall; + + // Collapse sidebar when viewport shrinks. + if ( hasViewportShrunk ) { + dispatch( 'core/edit-post' ).closeGeneralSidebar(); + } +} ); export default store; diff --git a/edit-post/store/mobile.js b/edit-post/store/mobile.js deleted file mode 100644 index 797ce3a3a96df..0000000000000 --- a/edit-post/store/mobile.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Enhance a redux store with the browser size. - * - * @param {Object} store Redux Store. - * @param {number} mobileBreakpoint The mobile breakpoint. - */ -function enhanceWithBrowserSize( store, mobileBreakpoint ) { - const updateSize = () => { - store.dispatch( { - type: 'UPDATE_MOBILE_STATE', - isMobile: window.innerWidth < mobileBreakpoint, - } ); - }; - - const mediaQueryList = window.matchMedia( `(min-width: ${ mobileBreakpoint }px)` ); - mediaQueryList.addListener( updateSize ); - window.addEventListener( 'orientationchange', updateSize ); - updateSize(); -} - -export default enhanceWithBrowserSize; diff --git a/edit-post/store/reducer.js b/edit-post/store/reducer.js index 1fa763747c688..19318772f2e3b 100644 --- a/edit-post/store/reducer.js +++ b/edit-post/store/reducer.js @@ -53,23 +53,6 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) { [ action.panel ]: ! get( state, [ 'panels', action.panel ], false ), }, }; - case 'SET_VIEWPORT_TYPE': - return { - ...state, - viewportType: action.viewportType, - }; - case 'UPDATE_MOBILE_STATE': - if ( action.isMobile ) { - return { - ...state, - viewportType: 'mobile', - activeGeneralSidebar: null, - }; - } - return { - ...state, - viewportType: 'desktop', - }; case 'SWITCH_MODE': return { ...state, @@ -111,13 +94,6 @@ export function publishSidebarActive( state = false, action ) { return state; } -export function mobile( state = false, action ) { - if ( action.type === 'UPDATE_MOBILE_STATE' ) { - return action.isMobile; - } - return state; -} - const locations = [ 'normal', 'side', @@ -191,7 +167,6 @@ export default combineReducers( { preferences, panel, publishSidebarActive, - mobile, metaBoxes, isSavingMetaBoxes, } ); diff --git a/edit-post/store/selectors.js b/edit-post/store/selectors.js index bbf325d635193..65d5d6a0f9108 100644 --- a/edit-post/store/selectors.js +++ b/edit-post/store/selectors.js @@ -121,29 +121,6 @@ export function isEditorSidebarPanelOpened( state, panel ) { return panels ? !! panels[ panel ] : false; } -/** - * Returns true if the current window size corresponds to mobile resolutions (<= medium breakpoint). - * - * @param {Object} state Global application state. - * - * @return {boolean} Whether current window size corresponds to - * mobile resolutions. - */ -export function isMobile( state ) { - return state.mobile; -} - -/** - * Returns whether the toolbar should be fixed or not. - * - * @param {Object} state Global application state. - * - * @return {boolean} True if toolbar is fixed. - */ -export function hasFixedToolbar( state ) { - return ! isMobile( state ) && isFeatureActive( state, 'fixedToolbar' ); -} - /** * Returns whether the given feature is enabled or not. * diff --git a/edit-post/store/test/actions.js b/edit-post/store/test/actions.js index 7c1ff16c09d15..69059299fb423 100644 --- a/edit-post/store/test/actions.js +++ b/edit-post/store/test/actions.js @@ -9,7 +9,6 @@ import { openPublishSidebar, closePublishSidebar, togglePublishSidebar, - setViewportType, toggleFeature, requestMetaBoxUpdates, initializeMetaBoxState, @@ -80,16 +79,6 @@ describe( 'actions', () => { } ); } ); - describe( 'setViewportType', () => { - it( 'should return SET_VIEWPORT_TYPE action', () => { - const viewportType = 'mobile'; - expect( setViewportType( viewportType ) ).toEqual( { - type: 'SET_VIEWPORT_TYPE', - viewportType, - } ); - } ); - } ); - describe( 'toggleFeature', () => { it( 'should return TOGGLE_FEATURE action', () => { const feature = 'name'; diff --git a/edit-post/store/test/reducer.js b/edit-post/store/test/reducer.js index 4f6a140715542..9664b40977ab6 100644 --- a/edit-post/store/test/reducer.js +++ b/edit-post/store/test/reducer.js @@ -26,7 +26,6 @@ describe( 'state', () => { editorMode: 'visual', panels: { 'post-status': true }, features: { fixedToolbar: false }, - viewportType: 'desktop', } ); } ); diff --git a/edit-post/store/test/selectors.js b/edit-post/store/test/selectors.js index 689d30afdf6e4..0881ae1a5a286 100644 --- a/edit-post/store/test/selectors.js +++ b/edit-post/store/test/selectors.js @@ -7,8 +7,6 @@ import { isGeneralSidebarPanelOpened, hasOpenSidebar, isEditorSidebarPanelOpened, - isMobile, - hasFixedToolbar, isFeatureActive, getMetaBoxes, hasMetaBoxes, @@ -16,10 +14,6 @@ import { getMetaBox, } from '../selectors'; -jest.mock( '../constants', () => ( { - BREAK_MEDIUM: 500, -} ) ); - describe( 'selectors', () => { describe( 'getEditorMode', () => { it( 'should return the selected editor mode', () => { @@ -70,7 +64,6 @@ describe( 'selectors', () => { const state = { preferences: { activeGeneralSidebar: 'editor', - viewportType: 'desktop', activeSidebarPanel: 'document', }, }; @@ -84,7 +77,6 @@ describe( 'selectors', () => { const state = { preferences: { activeGeneralSidebar: 'editor', - viewportType: 'desktop', activeSidebarPanel: 'blocks', }, }; @@ -98,7 +90,6 @@ describe( 'selectors', () => { const state = { preferences: { activeGeneralSidebar: null, - viewportType: 'desktop', activeSidebarPanel: null, }, }; @@ -158,78 +149,6 @@ describe( 'selectors', () => { } ); } ); - describe( 'isMobile', () => { - it( 'should return true if resolution is equal or less than medium breakpoint', () => { - const state = { - mobile: true, - }; - - expect( isMobile( state ) ).toBe( true ); - } ); - - it( 'should return true if resolution is greater than medium breakpoint', () => { - const state = { - mobile: false, - }; - - expect( isMobile( state ) ).toBe( false ); - } ); - } ); - - describe( 'hasFixedToolbar', () => { - it( 'should return true if fixedToolbar is active and is not mobile screen size', () => { - const state = { - mobile: false, - preferences: { - features: { - fixedToolbar: true, - }, - }, - }; - - expect( hasFixedToolbar( state ) ).toBe( true ); - } ); - - it( 'should return false if fixedToolbar is active and is mobile screen size', () => { - const state = { - mobile: true, - preferences: { - features: { - fixedToolbar: true, - }, - }, - }; - - expect( hasFixedToolbar( state ) ).toBe( false ); - } ); - - it( 'should return false if fixedToolbar is disable and is not mobile screen size', () => { - const state = { - mobile: false, - preferences: { - features: { - fixedToolbar: false, - }, - }, - }; - - expect( hasFixedToolbar( state ) ).toBe( false ); - } ); - - it( 'should return false if fixedToolbar is disable and is mobile screen size', () => { - const state = { - mobile: true, - preferences: { - features: { - fixedToolbar: false, - }, - }, - }; - - expect( hasFixedToolbar( state ) ).toBe( false ); - } ); - } ); - describe( 'isFeatureActive', () => { it( 'should return true if feature is active', () => { const state = { diff --git a/lib/client-assets.php b/lib/client-assets.php index 31d83b1b2bbe8..dcc9ab56cc571 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -158,6 +158,12 @@ function gutenberg_register_scripts_and_styles() { ) ), 'before' ); + wp_register_script( + 'wp-viewport', + gutenberg_url( 'viewport/build/index.js' ), + array( 'wp-element', 'wp-data', 'wp-components' ), + filemtime( gutenberg_dir_path() . 'viewport/build/index.js' ) + ); // Loading the old editor and its config to ensure the classic block works as expected. wp_add_inline_script( 'wp-blocks', 'window.wp.oldEditor = window.wp.editor;', 'before' @@ -237,7 +243,7 @@ function gutenberg_register_scripts_and_styles() { wp_register_script( 'wp-edit-post', gutenberg_url( 'edit-post/build/index.js' ), - array( 'jquery', 'heartbeat', 'wp-element', 'wp-components', 'wp-editor', 'wp-i18n', 'wp-date', 'wp-utils', 'wp-data', 'wp-embed' ), + array( 'jquery', 'heartbeat', 'wp-element', 'wp-components', 'wp-editor', 'wp-i18n', 'wp-date', 'wp-utils', 'wp-data', 'wp-embed', 'wp-viewport' ), filemtime( gutenberg_dir_path() . 'edit-post/build/index.js' ), true ); diff --git a/package-lock.json b/package-lock.json index 3ce2b1b4a3eda..d2d2790b61376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11356,12 +11356,6 @@ } } }, - "sass-variables-loader": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/sass-variables-loader/-/sass-variables-loader-0.1.3.tgz", - "integrity": "sha1-TwwvYJzRVKobFmnct+/paYeGJDw=", - "dev": true - }, "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", diff --git a/package.json b/package.json index 4aee9b19470d7..d2fcd1e37b832 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "raw-loader": "0.5.1", "react-test-renderer": "16.0.0", "sass-loader": "6.0.6", - "sass-variables-loader": "0.1.3", "sprintf-js": "1.1.1", "style-loader": "0.18.2", "tinymce": "4.7.2", @@ -116,10 +115,10 @@ }, "jest": { "collectCoverageFrom": [ - "(blocks|components|date|editor|element|i18n|data|utils|edit-post)/**/*.js" + "(blocks|components|date|editor|element|i18n|data|utils|edit-post|viewport)/**/*.js" ], "moduleNameMapper": { - "@wordpress\\/(blocks|components|date|editor|element|i18n|data|utils|edit-post)": "$1" + "@wordpress\\/(blocks|components|date|editor|element|i18n|data|utils|edit-post|viewport)": "$1" }, "preset": "@wordpress/jest-preset-default", "setupFiles": [ diff --git a/test/unit/setup-wp-aliases.js b/test/unit/setup-wp-aliases.js index e532151560bd3..d93003dc4079c 100644 --- a/test/unit/setup-wp-aliases.js +++ b/test/unit/setup-wp-aliases.js @@ -15,6 +15,7 @@ global.wp = { 'editor', 'data', 'edit-post', + 'viewport', ].forEach( entryPointName => { Object.defineProperty( global.wp, entryPointName, { get: () => require( entryPointName ), diff --git a/viewport/README.md b/viewport/README.md new file mode 100644 index 0000000000000..7ff4c22c7f395 --- /dev/null +++ b/viewport/README.md @@ -0,0 +1,65 @@ +Viewport +======== + +Viewport is a module for responding to changes in the browser viewport size. It registers its own [data module](https://github.com/WordPress/gutenberg/tree/master/data), updated in response to browser media queries on a standard set of supported breakpoints. This data and the included higher-order components can be used in your own modules and components to implement viewport-dependent behaviors. + +## Breakpoints + +The standard set of breakpoint thresholds is as follows: + +Name|Pixel Width +---|--- +`huge`|1440 +`wide`|1280 +`large`|960 +`medium`|782 +`small`|600 +`mobile`|480 + +## Data Module + +The Viewport module registers itself under the `core/viewport` data namespace. + +```js +const isSmall = select( 'core/viewport' ).isViewportMatch( '< medium' ); +``` + +The `isViewportMatch` selector accepts a single string argument `query`. It consists of an optional operator and breakpoint name, separated with a space. The operator can be `<` or `>=`, defaulting to `>=`. + +```js +const { isViewportMatch } = select( 'core/viewport' ); +const isSmall = isViewportMatch( '< medium' ); +const isWideOrHuge = isViewportMatch( '>= wide' ); +// Equivalent: +// const isWideOrHuge = isViewportMatch( 'wide' ); +``` + +## `ifViewportMatches` Higher-Order Component + +If you are authoring a component which should only be shown under a specific viewport condition, you can leverage the `ifViewportMatches` higher-order component to achieve this requirement. + +Pass a viewport query to render the component only when the query is matched: + +```jsx +function MyMobileComponent() { + return
I'm only rendered on mobile viewports!
; +} + +MyMobileComponent = ifViewportMatches( '< small' )( MyMobileComponent ); +``` + +## `withViewportMatch` Higher-Order Component + +If you are authoring a component which should vary its rendering behavior depending upon the matching viewport, you can leverage the `withViewportMatch` higher-order component to achieve this requirement. + +Pass an object, where each key is a prop name and its value a viewport query. The component will be rendered with your prop(s) assigned with the result(s) of the query match: + +```jsx +function MyComponent( { isMobile } ) { + return ( +
Currently: { isMobile ? 'Mobile' : 'Not Mobile' }
+ ); +} + +MyComponent = withViewportMatch( { isMobile: '< small' } )( MyComponent ); +``` diff --git a/viewport/if-viewport-matches.js b/viewport/if-viewport-matches.js new file mode 100644 index 0000000000000..4fb9c4daf190d --- /dev/null +++ b/viewport/if-viewport-matches.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { compose, getWrapperDisplayName } from '@wordpress/element'; +import { ifCondition } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import withViewportMatch from './with-viewport-match'; + +/** + * Higher-order component creator, creating a new component which renders if + * the viewport query is satisfied. + * + * @param {string} query Viewport query. + * + * @see withViewportMatches + * + * @return {Function} Higher-order component. + */ +const ifViewportMatches = ( query ) => ( WrappedComponent ) => { + const EnhancedComponent = compose( [ + withViewportMatch( { + isViewportMatch: query, + } ), + ifCondition( ( props ) => props.isViewportMatch ), + ] )( WrappedComponent ); + + EnhancedComponent.displayName = getWrapperDisplayName( WrappedComponent, 'ifViewportMatches' ); + + return EnhancedComponent; +}; + +export default ifViewportMatches; diff --git a/viewport/index.js b/viewport/index.js new file mode 100644 index 0000000000000..a8b19616e5c8c --- /dev/null +++ b/viewport/index.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import { reduce, forEach, debounce, mapValues, property } from 'lodash'; + +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import './store'; + +export { default as ifViewportMatches } from './if-viewport-matches'; +export { default as withViewportMatch } from './with-viewport-match'; + +/** + * Hash of breakpoint names with pixel width at which it becomes effective. + * + * @see _breakpoints.scss + * + * @type {Object} + */ +const BREAKPOINTS = { + huge: 1440, + wide: 1280, + large: 960, + medium: 782, + small: 600, + mobile: 480, +}; + +/** + * Hash of query operators with corresponding condition for media query. + * + * @type {Object} + */ +const OPERATORS = { + '<': 'max-width', + '>=': 'min-width', +}; + +/** + * Callback invoked when media query state should be updated. Is invoked a + * maximum of one time per call stack. + */ +const setIsMatching = debounce( () => { + const values = mapValues( queries, property( 'matches' ) ); + dispatch( 'core/viewport' ).setIsMatching( values ); +}, { leading: true } ); + +/** + * Hash of breakpoint names with generated MediaQueryList for corresponding + * media query. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia + * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList + * + * @type {Object} + */ +const queries = reduce( BREAKPOINTS, ( result, width, name ) => { + forEach( OPERATORS, ( condition, operator ) => { + const list = window.matchMedia( `(${ condition }: ${ width }px)` ); + list.addListener( setIsMatching ); + + const key = [ operator, name ].join( ' ' ); + result[ key ] = list; + } ); + + return result; +}, {} ); + +window.addEventListener( 'orientationchange', setIsMatching ); + +// Set initial values +setIsMatching(); diff --git a/viewport/store/actions.js b/viewport/store/actions.js new file mode 100644 index 0000000000000..0da1576677c0e --- /dev/null +++ b/viewport/store/actions.js @@ -0,0 +1,15 @@ +/** + * Returns an action object used in signalling that viewport queries have been + * updated. Values are specified as an object of breakpoint query keys where + * value represents whether query matches. + * + * @param {Object} values Breakpoint query matches. + * + * @return {Object} Action object. + */ +export function setIsMatching( values ) { + return { + type: 'SET_IS_MATCHING', + values, + }; +} diff --git a/viewport/store/index.js b/viewport/store/index.js new file mode 100644 index 0000000000000..88c7cba476165 --- /dev/null +++ b/viewport/store/index.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; + +export default registerStore( 'core/viewport', { + reducer, + actions, + selectors, +} ); diff --git a/viewport/store/reducer.js b/viewport/store/reducer.js new file mode 100644 index 0000000000000..22efbda63191e --- /dev/null +++ b/viewport/store/reducer.js @@ -0,0 +1,19 @@ +/** + * Reducer returning the viewport state, as keys of breakpoint queries with + * boolean value representing whether query is matched. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +function reducer( state = {}, action ) { + switch ( action.type ) { + case 'SET_IS_MATCHING': + return action.values; + } + + return state; +} + +export default reducer; diff --git a/viewport/store/selectors.js b/viewport/store/selectors.js new file mode 100644 index 0000000000000..ea8308d6ee156 --- /dev/null +++ b/viewport/store/selectors.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { takeRight } from 'lodash'; + +/** + * Returns true if the viewport matches the given query, or false otherwise. + * + * @param {Object} state Viewport state object. + * @param {string} query Query string. Includes operator and breakpoint name, + * space separated. Operator defaults to >=. + * + * @example + * + * ```js + * isViewportMatch( state, '< huge' ); + * isViewPortMatch( state, 'medium' ); + * ``` + * + * @return {boolean} Whether viewport matches query. + */ +export function isViewportMatch( state, query ) { + // Pad to _at least_ two elements to take from the right, effectively + // defaulting the left-most value. + const key = takeRight( [ '>=', ...query.split( ' ' ) ], 2 ).join( ' ' ); + + return !! state[ key ]; +} diff --git a/viewport/store/test/reducer.js b/viewport/store/test/reducer.js new file mode 100644 index 0000000000000..ffec6ca706c31 --- /dev/null +++ b/viewport/store/test/reducer.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import reducer from '../reducer'; + +describe( 'reducer', () => { + it( 'defaults to an empty object', () => { + const state = reducer( undefined, {} ); + + expect( state ).toEqual( {} ); + } ); + + it( 'replaces its state in response to new matching values', () => { + const original = deepFreeze( reducer( undefined, {} ) ); + const state = reducer( original, { + type: 'SET_IS_MATCHING', + values: { + huge: true, + }, + } ); + + expect( state ).toEqual( { + huge: true, + } ); + } ); +} ); diff --git a/viewport/store/test/selectors.js b/viewport/store/test/selectors.js new file mode 100644 index 0000000000000..d05406ddfe679 --- /dev/null +++ b/viewport/store/test/selectors.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { isViewportMatch } from '../selectors'; + +describe( 'selectors', () => { + describe( 'isViewportMatch()', () => { + it( 'should return with omitted operator defaulting to >=', () => { + const result = isViewportMatch( { + '>= wide': true, + '< wide': false, + }, 'wide' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return with known query value', () => { + const result = isViewportMatch( { + '>= wide': false, + '< wide': true, + }, '< wide' ); + + expect( result ).toBe( true ); + } ); + } ); +} ); diff --git a/viewport/test/if-viewport-matches.js b/viewport/test/if-viewport-matches.js new file mode 100644 index 0000000000000..d1f2c1a247d49 --- /dev/null +++ b/viewport/test/if-viewport-matches.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import '../store'; +import ifViewportMatches from '../if-viewport-matches'; + +describe( 'ifViewportMatches()', () => { + const Component = () =>
Hello
; + + it( 'should not render if query does not match', () => { + dispatch( 'core/viewport' ).setIsMatching( { '> wide': false } ); + const EnhancedComponent = ifViewportMatches( '> wide' )( Component ); + const wrapper = mount( ); + + expect( wrapper.find( Component ) ).toHaveLength( 0 ); + + wrapper.unmount(); + } ); + + it( 'should render if query does match', () => { + dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } ); + const EnhancedComponent = ifViewportMatches( '> wide' )( Component ); + const wrapper = mount( ); + + expect( wrapper.find( Component ).childAt( 0 ).type() ).toBe( 'div' ); + + wrapper.unmount(); + } ); +} ); diff --git a/viewport/test/with-viewport-match.js b/viewport/test/with-viewport-match.js new file mode 100644 index 0000000000000..a2d17ded6e956 --- /dev/null +++ b/viewport/test/with-viewport-match.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import '../store'; +import withViewportMatch from '../with-viewport-match'; + +describe( 'withViewportMatch()', () => { + const Component = () =>
Hello
; + + it( 'should render with result of query as custom prop name', () => { + dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } ); + const EnhancedComponent = withViewportMatch( { isWide: '> wide' } )( Component ); + const wrapper = mount( ); + + expect( wrapper.find( Component ).props() ).toEqual( { isWide: true } ); + + wrapper.unmount(); + } ); +} ); diff --git a/viewport/with-viewport-match.js b/viewport/with-viewport-match.js new file mode 100644 index 0000000000000..129708a59ecfa --- /dev/null +++ b/viewport/with-viewport-match.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { mapValues } from 'lodash'; + +/** + * WordPress dependencies + */ +import { getWrapperDisplayName } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; + +/** + * Higher-order component creator, creating a new component which renders with + * the given prop names, where the value passed to the underlying compoennt is + * the result of the query assigned as the object's value. + * + * @param {Object} queries Object of prop name to viewport query. + * @param {string} propName Optional prop name to which result is assigned. + * + * @see isViewportMatch + * + * @return {Function} Higher-order component. + */ +const withViewportMatch = ( queries ) => ( WrappedComponent ) => { + const EnhancedComponent = withSelect( ( select ) => { + return mapValues( queries, ( query ) => { + return select( 'core/viewport' ).isViewportMatch( query ); + } ); + } )( WrappedComponent ); + + EnhancedComponent.displayName = getWrapperDisplayName( WrappedComponent, 'withViewportMatch' ); + + return EnhancedComponent; +}; + +export default withViewportMatch; diff --git a/webpack.config.js b/webpack.config.js index 740f261fffa33..276ceb4c4db27 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -55,6 +55,7 @@ const entryPointNames = [ 'i18n', 'utils', 'data', + 'viewport', [ 'editPost', 'edit-post' ], ];