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' ],
];