diff --git a/docs/manifest-devhub.json b/docs/manifest-devhub.json index b508f649fb7c9e..a8d1a369642ee2 100644 --- a/docs/manifest-devhub.json +++ b/docs/manifest-devhub.json @@ -641,6 +641,12 @@ "markdown_source": "../packages/components/src/date-time/README.md", "parent": "components" }, + { + "title": "DimensionControl", + "slug": "dimension-control", + "markdown_source": "../packages/components/src/dimension-control/README.md", + "parent": "components" + }, { "title": "Disabled", "slug": "disabled", diff --git a/packages/components/src/dimension-control/README.md b/packages/components/src/dimension-control/README.md new file mode 100644 index 00000000000000..435fdf4e73b046 --- /dev/null +++ b/packages/components/src/dimension-control/README.md @@ -0,0 +1,120 @@ +DimensionControl +============================= + +`DimensionControl` is a component designed to provide a UI to control spacing and/or dimensions. + +## Usage + +In a block's `edit` implementation, render a `` component. + + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { + DimensionControl, +} from '@wordpress/block-editor'; + +registerBlockType( 'my-plugin/my-block', { + // ... + + attributes: { + // other attributes here + // ... + + paddingSize: { + type: 'string', + }, + }, + + edit( { attributes, setAttributes, clientId } ) { + + const { paddingSize } = attributes; + + + const updateSpacing = ( dimension, size, device = '' ) => { + setAttributes( { + [ `${ dimension }${ device }` ]: size, + } ); + }; + + return ( + + ); + } +} ); +``` + +_Note:_ it is recommended to partially apply the value of the Block attribute to be updated (eg: `paddingSize`, `marginSize`...etc) to your callback functions. This avoids the need to unnecessarily couple the component to the Block attribute schema. + +_Note:_ by default, if you do not provide an initial `value` prop for the current dimension value, then no value will be selected (ie: there is no default dimension set). + +## Props + +### `label` +* **Type:** `String` +* **Default:** `undefined` +* **Required:** Yes + +The human readable label for the control. + +### `value` +* **Type:** `String` +* **Default:** `''` +* **Required:** No + +The current value of the dimension UI control. If provided the UI with automatically select the value. + +### `sizes` +* **Type:** `Array` +* **Default:** See `packages/block-editor/src/components/dimension-control/sizes.js` +* **Required:** No + +An optional array of size objects in the following shape: + +``` +[ + { + name: __( 'Small' ), + slug: 'small', + }, + { + name: __( 'Medium' ), + slug: 'small', + }, + // ...etc +] +``` + +By default a set of relative sizes (`small`, `medium`...etc) are provided. See `packages/block-editor/src/components/dimension-control/sizes.js`. + +### `icon` +* **Type:** `String` +* **Default:** `undefined` +* **Required:** No + +An optional dashicon to display before to the control label. + +### `onChange` +* **Type:** `Function` +* **Default:** `undefined` +* **Required:** No +* **Arguments:**: + - `size` - a string representing the selected size (eg: `medium`) + +A callback which is triggered when a spacing size value changes (is selected/clicked). + + +### `className` +* **Type:** `String` +* **Default:** `''` +* **Required:** No + +A string of classes to be added to the control component. + + diff --git a/packages/components/src/dimension-control/index.js b/packages/components/src/dimension-control/index.js new file mode 100644 index 00000000000000..c69b80f5c49e02 --- /dev/null +++ b/packages/components/src/dimension-control/index.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { isFunction } from 'lodash'; + +/** + * WordPress dependencies + */ +/** + * Internal dependencies + */ +import { + Icon, + SelectControl, +} from '../'; +import { __ } from '@wordpress/i18n'; + +import { + Fragment, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import sizesTable, { findSizeBySlug } from './sizes'; + +export function DimensionControl( props ) { + const { label, value, sizes = sizesTable, icon, onChange, className = '' } = props; + + const onChangeSpacingSize = ( val ) => { + const theSize = findSizeBySlug( sizes, val ); + + if ( ! theSize || value === theSize.slug ) { + onChange( undefined ); + } else if ( isFunction( onChange ) ) { + onChange( theSize.slug ); + } + }; + + const formatSizesAsOptions = ( theSizes ) => { + const options = theSizes.map( ( { name, slug } ) => ( { + label: name, + value: slug, + } ) ); + + return [ { + label: __( 'Default' ), + value: '', + } ].concat( options ); + }; + + const selectLabel = ( + + { icon && ( + + ) } + { label } + + ); + + return ( + + ); +} + +export default DimensionControl; diff --git a/packages/components/src/dimension-control/sizes.js b/packages/components/src/dimension-control/sizes.js new file mode 100644 index 00000000000000..41fa0717028725 --- /dev/null +++ b/packages/components/src/dimension-control/sizes.js @@ -0,0 +1,45 @@ +/** + * Sizes + * + * defines the sizes used in dimension controls + * all hardcoded `size` values are based on the value of + * the Sass variable `$block-padding` from + * `packages/block-editor/src/components/dimension-control/sizes.js`. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Finds the correct size object from the provided sizes + * table by size slug (eg: `medium`) + * + * @param {Array} sizes containing objects for each size definition + * @param {string} slug a string representation of the size (eg: `medium`) + * @return {Object} the matching size definition + */ +export const findSizeBySlug = ( sizes, slug ) => sizes.find( ( size ) => slug === size.slug ); + +export default [ + { + name: __( 'None' ), + slug: 'none', + }, + { + name: __( 'Small' ), + slug: 'small', + }, + { + name: __( 'Medium' ), + slug: 'medium', + }, + { + name: __( 'Large' ), + slug: 'large', + }, { + name: __( 'Extra Large' ), + slug: 'xlarge', + }, +]; diff --git a/packages/components/src/dimension-control/style.scss b/packages/components/src/dimension-control/style.scss new file mode 100644 index 00000000000000..7f1481747dfe1a --- /dev/null +++ b/packages/components/src/dimension-control/style.scss @@ -0,0 +1,22 @@ +.block-editor-dimension-control { + + .components-base-control__field { + display: flex; + align-items: center; + } + + .components-base-control__label { + display: flex; + align-items: center; + margin-right: 1em; + margin-bottom: 0; + + .dashicon { + margin-right: 0.5em; + } + } + + &.is-manual .components-base-control__label { + width: 10em; + } +} diff --git a/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap b/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap new file mode 100644 index 00000000000000..c06662ee862c99 --- /dev/null +++ b/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DimensionControl rendering renders with custom sizes 1`] = ` + + Custom Dimension + + } + onChange={[Function]} + options={ + Array [ + Object { + "label": "Default", + "value": "", + }, + Object { + "label": "Mini", + "value": "mini", + }, + Object { + "label": "Middle", + "value": "middle", + }, + Object { + "label": "Giant", + "value": "giant", + }, + ] + } +/> +`; + +exports[`DimensionControl rendering renders with defaults 1`] = ` + + Padding + + } + onChange={[Function]} + options={ + Array [ + Object { + "label": "Default", + "value": "", + }, + Object { + "label": "None", + "value": "none", + }, + Object { + "label": "Small", + "value": "small", + }, + Object { + "label": "Medium", + "value": "medium", + }, + Object { + "label": "Large", + "value": "large", + }, + Object { + "label": "Extra Large", + "value": "xlarge", + }, + ] + } +/> +`; + +exports[`DimensionControl rendering renders with icon and custom icon label 1`] = ` + + + Margin + + } + onChange={[Function]} + options={ + Array [ + Object { + "label": "Default", + "value": "", + }, + Object { + "label": "None", + "value": "none", + }, + Object { + "label": "Small", + "value": "small", + }, + Object { + "label": "Medium", + "value": "medium", + }, + Object { + "label": "Large", + "value": "large", + }, + Object { + "label": "Extra Large", + "value": "xlarge", + }, + ] + } +/> +`; + +exports[`DimensionControl rendering renders with icon and default icon label 1`] = ` + + + Margin + + } + onChange={[Function]} + options={ + Array [ + Object { + "label": "Default", + "value": "", + }, + Object { + "label": "None", + "value": "none", + }, + Object { + "label": "Small", + "value": "small", + }, + Object { + "label": "Medium", + "value": "medium", + }, + Object { + "label": "Large", + "value": "large", + }, + Object { + "label": "Extra Large", + "value": "xlarge", + }, + ] + } +/> +`; diff --git a/packages/components/src/dimension-control/test/index.test.js b/packages/components/src/dimension-control/test/index.test.js new file mode 100644 index 00000000000000..0b053a493ee123 --- /dev/null +++ b/packages/components/src/dimension-control/test/index.test.js @@ -0,0 +1,128 @@ +/** + * External dependencies + */ +import { shallow, mount } from 'enzyme'; +import { uniqueId } from 'lodash'; + +/** + * Internal dependencies + */ +import { DimensionControl } from '../'; + +describe( 'DimensionControl', () => { + const onChangeHandler = jest.fn(); + + afterEach( () => { + onChangeHandler.mockClear(); + } ); + + describe( 'rendering', () => { + it( 'renders with defaults', () => { + const wrapper = shallow( + + ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'renders with icon and default icon label', () => { + const wrapper = shallow( + + ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'renders with icon and custom icon label', () => { + const wrapper = shallow( + + ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'renders with custom sizes', () => { + const customSizes = [ + { + name: 'Mini', + size: 1, + slug: 'mini', + }, + { + name: 'Middle', + size: 5, + slug: 'middle', + }, + { + name: 'Giant', + size: 10, + slug: 'giant', + }, + ]; + + const wrapper = shallow( + + ); + expect( wrapper ).toMatchSnapshot(); + } ); + } ); + + describe( 'callbacks', () => { + it( 'should call onChange handler with correct args on size change', () => { + const wrapper = mount( + + ); + + wrapper.find( 'select' ).at( 0 ).simulate( 'change', { + target: { + value: 'small', + }, + } ); + + wrapper.find( 'select' ).at( 0 ).simulate( 'change', { + target: { + value: 'medium', + }, + } ); + + expect( onChangeHandler ).toHaveBeenCalledTimes( 2 ); + expect( onChangeHandler.mock.calls[ 0 ][ 0 ] ).toEqual( 'small' ); + expect( onChangeHandler.mock.calls[ 1 ][ 0 ] ).toEqual( 'medium' ); + } ); + + it( 'should call onChange handler with undefined value when no size is provided on change', () => { + const wrapper = mount( + + ); + + wrapper.find( 'select' ).at( 0 ).simulate( 'change', { + target: { + value: '', // this happens when you select the "default"