From a861f0ebb3303bf6b0d26e4a0b50aa66336fbfb4 Mon Sep 17 00:00:00 2001 From: Haz Date: Tue, 19 Nov 2019 06:29:16 -0300 Subject: [PATCH] Components: Add accessible Toolbar (#18534) * Add accessible Toolbar * Remove ToolbarButton from Button * Remove ref for now * Pass className to ToolbarGroup on Toolbar * Update Toolbar stories * Remove withInstanceId from Toolbar stories --- package-lock.json | 34 ++++++ packages/components/package.json | 1 + .../components/src/dropdown-menu/index.js | 14 ++- packages/components/src/index.js | 1 + packages/components/src/index.native.js | 1 + packages/components/src/style.scss | 1 + .../accessible-toolbar-button-container.js | 26 ++++ ...essible-toolbar-button-container.native.js | 6 + .../components/src/toolbar-button/index.js | 62 +++++++--- .../components/src/toolbar-button/style.scss | 1 + .../components/src/toolbar-context/index.js | 8 ++ .../components/src/toolbar-group/index.js | 112 ++++++++++++++++++ .../src/toolbar-group/stories/index.js | 28 +++++ .../src/toolbar-group/style.native.scss | 11 ++ .../components/src/toolbar-group/style.scss | 58 +++++++++ .../src/toolbar-group/test/index.js | 101 ++++++++++++++++ .../toolbar-group/toolbar-group-collapsed.js | 52 ++++++++ .../toolbar-group-collapsed.native.js | 18 +++ .../toolbar-group/toolbar-group-container.js | 6 + .../toolbar-group-container.native.js | 22 ++++ packages/components/src/toolbar/index.js | 78 ++---------- .../components/src/toolbar/stories/index.js | 75 ++++++++++++ packages/components/src/toolbar/style.scss | 40 +------ packages/components/src/toolbar/test/index.js | 108 +++++------------ .../src/toolbar/toolbar-container.js | 39 +++++- .../src/toolbar/toolbar-container.native.js | 18 +-- 26 files changed, 697 insertions(+), 224 deletions(-) create mode 100644 packages/components/src/toolbar-button/accessible-toolbar-button-container.js create mode 100644 packages/components/src/toolbar-button/accessible-toolbar-button-container.native.js create mode 100644 packages/components/src/toolbar-context/index.js create mode 100644 packages/components/src/toolbar-group/index.js create mode 100644 packages/components/src/toolbar-group/stories/index.js create mode 100644 packages/components/src/toolbar-group/style.native.scss create mode 100644 packages/components/src/toolbar-group/style.scss create mode 100644 packages/components/src/toolbar-group/test/index.js create mode 100644 packages/components/src/toolbar-group/toolbar-group-collapsed.js create mode 100644 packages/components/src/toolbar-group/toolbar-group-collapsed.native.js create mode 100644 packages/components/src/toolbar-group/toolbar-group-container.js create mode 100644 packages/components/src/toolbar-group/toolbar-group-container.native.js create mode 100644 packages/components/src/toolbar/stories/index.js diff --git a/package-lock.json b/package-lock.json index ba97a911bfbda9..e01dbfb4d978dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7325,6 +7325,7 @@ "re-resizable": "^6.0.0", "react-dates": "^17.1.1", "react-spring": "^8.0.20", + "reakit": "^1.0.0-beta.12", "rememo": "^3.0.0", "tinycolor2": "^1.4.1", "uuid": "^3.3.2" @@ -10500,6 +10501,11 @@ } } }, + "body-scroll-lock": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/body-scroll-lock/-/body-scroll-lock-2.6.4.tgz", + "integrity": "sha512-NP08WsovlmxEoZP9pdlqrE+AhNaivlTrz9a0FF37BQsnOrpN48eNqivKkE7SYpM9N+YIPjsdVzfLAUQDBm6OQw==" + }, "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -27704,6 +27710,34 @@ "readable-stream": "^2.0.2" } }, + "reakit": { + "version": "1.0.0-beta.12", + "resolved": "https://registry.npmjs.org/reakit/-/reakit-1.0.0-beta.12.tgz", + "integrity": "sha512-jf/0RWmJypG9wFbbCSj9mFxb474TCFnAweKnrh3yLJiMjKDEAFXic0cNyhqxSuOUUyZeT67bUFbu25DXBNfRmQ==", + "requires": { + "body-scroll-lock": "^2.6.4", + "popper.js": "^1.16.0", + "reakit-system": "^0.7.0", + "reakit-utils": "^0.7.1" + }, + "dependencies": { + "popper.js": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz", + "integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw==" + } + } + }, + "reakit-system": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/reakit-system/-/reakit-system-0.7.0.tgz", + "integrity": "sha512-6MaQsoyIhU0b0RGfIfGSSGujCx0XVBtfJkRcn+TviiWwMXGS9liTCDBE1vn7fLnUYiR6kqll50Nmw//oIn97cg==" + }, + "reakit-utils": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/reakit-utils/-/reakit-utils-0.7.1.tgz", + "integrity": "sha512-xQJctof9V+wkC7OxSL7P14d5Se6l/apCfhY8liIfVihtakzXOkvKea4Ka/TbEfpoTKN7MRO4xNMxjfzuGFexHQ==" + }, "realpath-native": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.1.0.tgz", diff --git a/packages/components/package.json b/packages/components/package.json index 3f4c6e84a88644..3fbb8ca0344ab3 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -45,6 +45,7 @@ "re-resizable": "^6.0.0", "react-dates": "^17.1.1", "react-spring": "^8.0.20", + "reakit": "^1.0.0-beta.12", "rememo": "^3.0.0", "tinycolor2": "^1.4.1", "uuid": "^3.3.2" diff --git a/packages/components/src/dropdown-menu/index.js b/packages/components/src/dropdown-menu/index.js index e94b246231ad1b..768e2edff5209d 100644 --- a/packages/components/src/dropdown-menu/index.js +++ b/packages/components/src/dropdown-menu/index.js @@ -98,8 +98,18 @@ function DropdownMenu( { { + onToggle( event ); + if ( mergedToggleProps.onClick ) { + mergedToggleProps.onClick( event ); + } + } } + onKeyDown={ ( event ) => { + openOnArrowDown( event ); + if ( mergedToggleProps.onKeyDown ) { + mergedToggleProps.onKeyDown( event ); + } + } } aria-haspopup="true" aria-expanded={ isOpen } label={ label } diff --git a/packages/components/src/index.js b/packages/components/src/index.js index 3803719c2d8d1b..2b4e9437cc330e 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -67,6 +67,7 @@ export { default as Tip } from './tip'; export { default as ToggleControl } from './toggle-control'; export { default as Toolbar } from './toolbar'; export { default as ToolbarButton } from './toolbar-button'; +export { default as ToolbarGroup } from './toolbar-group'; export { default as Tooltip } from './tooltip'; export { default as TreeSelect } from './tree-select'; export { default as VisuallyHidden } from './visually-hidden'; diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index 7bc509058690d6..7cda193234fcde 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -6,6 +6,7 @@ export { default as Dashicon } from './dashicon'; export { default as Dropdown } from './dropdown'; export { default as Toolbar } from './toolbar'; export { default as ToolbarButton } from './toolbar-button'; +export { default as ToolbarGroup } from './toolbar-group'; export { default as Icon } from './icon'; export { default as IconButton } from './icon-button'; export { default as Spinner } from './spinner'; diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index 7a62ba0534539d..bba2e5e0c5fc2f 100644 --- a/packages/components/src/style.scss +++ b/packages/components/src/style.scss @@ -44,5 +44,6 @@ @import "./toggle-control/style.scss"; @import "./toolbar/style.scss"; @import "./toolbar-button/style.scss"; +@import "./toolbar-group/style.scss"; @import "./tooltip/style.scss"; @import "./visually-hidden/style.scss"; diff --git a/packages/components/src/toolbar-button/accessible-toolbar-button-container.js b/packages/components/src/toolbar-button/accessible-toolbar-button-container.js new file mode 100644 index 00000000000000..5d026c4b83e8c9 --- /dev/null +++ b/packages/components/src/toolbar-button/accessible-toolbar-button-container.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { useToolbarItem } from 'reakit/Toolbar'; + +/** + * WordPress dependencies + */ +import { Children, cloneElement, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ToolbarContext from '../toolbar-context'; + +function AccessibleToolbarButtonContainer( props ) { + const accessibleToolbarState = useContext( ToolbarContext ); + const childButton = Children.only( props.children ); + + // https://reakit.io/docs/composition/#props-hooks + const itemHTMLProps = useToolbarItem( accessibleToolbarState, childButton.props ); + + return
{ cloneElement( childButton, itemHTMLProps ) }
; +} + +export default AccessibleToolbarButtonContainer; diff --git a/packages/components/src/toolbar-button/accessible-toolbar-button-container.native.js b/packages/components/src/toolbar-button/accessible-toolbar-button-container.native.js new file mode 100644 index 00000000000000..7fe5e6657bcd10 --- /dev/null +++ b/packages/components/src/toolbar-button/accessible-toolbar-button-container.native.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import ToolbarButtonContainer from './toolbar-button-container'; + +export default ToolbarButtonContainer; diff --git a/packages/components/src/toolbar-button/index.js b/packages/components/src/toolbar-button/index.js index 8c2374b0284d1d..0dd438b47260eb 100644 --- a/packages/components/src/toolbar-button/index.js +++ b/packages/components/src/toolbar-button/index.js @@ -3,10 +3,17 @@ */ import classnames from 'classnames'; +/** + * WordPress dependencies + */ +import { useContext } from '@wordpress/element'; + /** * Internal dependencies */ import IconButton from '../icon-button'; +import ToolbarContext from '../toolbar-context'; +import AccessibleToolbarButtonContainer from './accessible-toolbar-button-container'; import ToolbarButtonContainer from './toolbar-button-container'; function ToolbarButton( { @@ -22,26 +29,45 @@ function ToolbarButton( { extraProps, children, } ) { + // It'll contain state if `ToolbarButton` is being used within + // `` + const accessibleToolbarState = useContext( ToolbarContext ); + + const button = ( + { + event.stopPropagation(); + if ( onClick ) { + onClick( event ); + } + } } + className={ classnames( + 'components-toolbar__control', + className, + { 'is-active': isActive } + ) } + aria-pressed={ isActive } + disabled={ isDisabled } + { ...extraProps } + /> + ); + + if ( accessibleToolbarState ) { + return ( + + { button } + + ); + } + + // ToolbarButton is being used outside of the accessible Toolbar return ( - { - event.stopPropagation(); - onClick(); - } } - className={ classnames( - 'components-toolbar__control', - className, - { 'is-active': isActive } - ) } - aria-pressed={ isActive } - disabled={ isDisabled } - { ...extraProps } - /> + { button } { children } ); diff --git a/packages/components/src/toolbar-button/style.scss b/packages/components/src/toolbar-button/style.scss index cd434448784a20..c7e64b5a04bf10 100644 --- a/packages/components/src/toolbar-button/style.scss +++ b/packages/components/src/toolbar-button/style.scss @@ -29,6 +29,7 @@ border-radius: $radius-round-rectangle; height: 30px; width: 30px; + box-sizing: border-box; } // Subscript for numbered icon buttons, like headings diff --git a/packages/components/src/toolbar-context/index.js b/packages/components/src/toolbar-context/index.js new file mode 100644 index 00000000000000..a5095c503f6ece --- /dev/null +++ b/packages/components/src/toolbar-context/index.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; + +const ToolbarContext = createContext(); + +export default ToolbarContext; diff --git a/packages/components/src/toolbar-group/index.js b/packages/components/src/toolbar-group/index.js new file mode 100644 index 00000000000000..866a671875c18e --- /dev/null +++ b/packages/components/src/toolbar-group/index.js @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { flatMap } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ToolbarButton from '../toolbar-button'; +import ToolbarGroupContainer from './toolbar-group-container'; +import ToolbarGroupCollapsed from './toolbar-group-collapsed'; +import ToolbarContext from '../toolbar-context'; + +/** + * Renders a collapsible group of controls + * + * The `controls` prop accepts an array of sets. A set is an array of controls. + * Controls have the following shape: + * + * ``` + * { + * icon: string, + * title: string, + * subscript: string, + * onClick: Function, + * isActive: boolean, + * isDisabled: boolean + * } + * ``` + * + * For convenience it is also possible to pass only an array of controls. It is + * then assumed this is the only set. + * + * Either `controls` or `children` is required, otherwise this components + * renders nothing. + * + * @param {Object} props Component props. + * @param {Array} [props.controls] The controls to render in this toolbar. + * @param {WPElement} [props.children] Any other things to render inside the toolbar besides the controls. + * @param {string} [props.className] Class to set on the container div. + * @param {boolean} [props.isCollapsed] Turns ToolbarGroup into a dropdown menu. + * @param {WPBlockTypeIconRender} [props.icon] The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element. + * @param {string} [props.label] The menu item text. + */ +function ToolbarGroup( { + controls = [], + children, + className, + isCollapsed, + icon, + title, + ...otherProps +} ) { + // It'll contain state if `ToolbarGroup` is being used within + // `` + const accessibleToolbarState = useContext( ToolbarContext ); + + if ( ( ! controls || ! controls.length ) && ! children ) { + return null; + } + + const finalClassName = classnames( + // Unfortunately, there's legacy code referencing to `.components-toolbar` + // So we can't get rid of it + accessibleToolbarState ? 'components-toolbar-group' : 'components-toolbar', + className + ); + + // Normalize controls to nested array of objects (sets of controls) + let controlSets = controls; + if ( ! Array.isArray( controlSets[ 0 ] ) ) { + controlSets = [ controlSets ]; + } + + if ( isCollapsed ) { + return ( + + ); + } + + return ( + + { flatMap( controlSets, ( controlSet, indexOfSet ) => + controlSet.map( ( control, indexOfControl ) => ( + 0 && indexOfControl === 0 ? 'has-left-divider' : null + } + { ...control } + /> + ) ) + ) } + { children } + + ); +} + +export default ToolbarGroup; diff --git a/packages/components/src/toolbar-group/stories/index.js b/packages/components/src/toolbar-group/stories/index.js new file mode 100644 index 00000000000000..3f969e1ee08c47 --- /dev/null +++ b/packages/components/src/toolbar-group/stories/index.js @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import { ToolbarButton, ToolbarGroup } from '../../'; + +export default { title: 'Components|ToolbarGroup', component: ToolbarGroup }; + +export const _default = () => { + return ( + + + + + + ); +}; + +export const withControlsProp = () => { + return ( + + ); +}; diff --git a/packages/components/src/toolbar-group/style.native.scss b/packages/components/src/toolbar-group/style.native.scss new file mode 100644 index 00000000000000..e218aa37363e37 --- /dev/null +++ b/packages/components/src/toolbar-group/style.native.scss @@ -0,0 +1,11 @@ +.container { + flex-direction: row; + border-left-width: 1px; + border-color: #e9eff3; + padding-left: 5px; + padding-right: 5px; +} + +.containerDark { + border-color: #525354; +} diff --git a/packages/components/src/toolbar-group/style.scss b/packages/components/src/toolbar-group/style.scss new file mode 100644 index 00000000000000..e03bb5a1a7a640 --- /dev/null +++ b/packages/components/src/toolbar-group/style.scss @@ -0,0 +1,58 @@ +.components-toolbar-group { + border: $border-width solid $light-gray-500; + background-color: $white; + display: flex; + flex-shrink: 0; + margin-right: -$border-width; + + & & { + border-width: 0; + margin: 0; + } + + line-height: 0; +} + +// Legacy toolbar group +// External code references to it, so we can't change it? +.components-toolbar { + margin: 0; + border: $border-width solid $light-gray-500; + background-color: $white; + display: flex; + flex-shrink: 0; +} + +div.components-toolbar { + & > div { + // IE11 does not support `position: sticky`, or Flex very well, so use block. + display: block; + @supports (position: sticky) { + display: flex; + } + + margin: 0; + } + + & > div + div { + margin-left: -3px; + + &.has-left-divider { + margin-left: 6px; + position: relative; + overflow: visible; + } + + &.has-left-divider::before { + display: inline-block; + content: ""; + box-sizing: content-box; + background-color: $light-gray-500; + position: absolute; + top: 8px; + left: -3px; + width: 1px; + height: $icon-button-size - 16px; + } + } +} diff --git a/packages/components/src/toolbar-group/test/index.js b/packages/components/src/toolbar-group/test/index.js new file mode 100644 index 00000000000000..972dca89499f78 --- /dev/null +++ b/packages/components/src/toolbar-group/test/index.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import ToolbarGroup from '../'; + +describe( 'ToolbarGroup', () => { + describe( 'basic rendering', () => { + it( 'should render an empty node, when controls are not passed', () => { + const wrapper = mount( ); + expect( wrapper.html() ).toBeNull(); + } ); + + it( 'should render an empty node, when controls are empty', () => { + const wrapper = mount( ); + expect( wrapper.html() ).toBeNull(); + } ); + + it( 'should render a list of controls with buttons', () => { + const clickHandler = ( event ) => event; + const controls = [ + { + icon: 'wordpress', + title: 'WordPress', + onClick: clickHandler, + isActive: false, + }, + ]; + const wrapper = mount( ); + const button = wrapper.find( '[aria-label="WordPress"]' ).hostNodes(); + expect( button.props() ).toMatchObject( { + 'aria-label': 'WordPress', + 'aria-pressed': false, + type: 'button', + } ); + } ); + + it( 'should render a list of controls with buttons and active control', () => { + const clickHandler = ( event ) => event; + const controls = [ + { + icon: 'wordpress', + title: 'WordPress', + onClick: clickHandler, + isActive: true, + }, + ]; + const wrapper = mount( ); + const button = wrapper.find( '[aria-label="WordPress"]' ).hostNodes(); + expect( button.props() ).toMatchObject( { + 'aria-label': 'WordPress', + 'aria-pressed': true, + type: 'button', + } ); + } ); + + it( 'should render a nested list of controls with separator between', () => { + const controls = [ + [ // First set + { + icon: 'wordpress', + title: 'WordPress', + }, + ], + [ // Second set + { + icon: 'wordpress', + title: 'WordPress', + }, + ], + ]; + + const wrapper = mount( ); + const buttons = wrapper.find( 'button' ).hostNodes(); + const hasLeftDivider = wrapper.find( '.has-left-divider' ).hostNodes(); + expect( buttons ).toHaveLength( 2 ); + expect( hasLeftDivider ).toHaveLength( 1 ); + expect( hasLeftDivider.html() ).toContain( buttons.at( 1 ).html() ); + } ); + + it( 'should call the clickHandler on click.', () => { + const clickHandler = jest.fn(); + const controls = [ + { + icon: 'wordpress', + title: 'WordPress', + onClick: clickHandler, + isActive: true, + }, + ]; + const wrapper = mount( ); + const button = wrapper.find( '[aria-label="WordPress"]' ).hostNodes(); + button.simulate( 'click' ); + expect( clickHandler ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/packages/components/src/toolbar-group/toolbar-group-collapsed.js b/packages/components/src/toolbar-group/toolbar-group-collapsed.js new file mode 100644 index 00000000000000..8e684a7cae72ac --- /dev/null +++ b/packages/components/src/toolbar-group/toolbar-group-collapsed.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { ToolbarItem } from 'reakit/Toolbar'; + +/** + * WordPress dependencies + */ +import { useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import DropdownMenu from '../dropdown-menu'; +import ToolbarContext from '../toolbar-context'; + +function ToolbarGroupCollapsed( { + controls = [], + className, + icon, + label, + ...props +} ) { + // It'll contain state if `ToolbarGroup` is being used within + // `` + const accessibleToolbarState = useContext( ToolbarContext ); + + const renderDropdownMenu = ( toggleProps ) => ( + + ); + + if ( accessibleToolbarState ) { + return ( + // https://reakit.io/docs/composition/#render-props + + { ( toolbarItemHTMLProps ) => renderDropdownMenu( toolbarItemHTMLProps ) } + + ); + } + + return renderDropdownMenu(); +} + +export default ToolbarGroupCollapsed; diff --git a/packages/components/src/toolbar-group/toolbar-group-collapsed.native.js b/packages/components/src/toolbar-group/toolbar-group-collapsed.native.js new file mode 100644 index 00000000000000..718e1238792c43 --- /dev/null +++ b/packages/components/src/toolbar-group/toolbar-group-collapsed.native.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import DropdownMenu from '../dropdown-menu'; + +function ToolbarGroupCollapsed( { controls = [], className, icon, label } ) { + return ( + + ); +} + +export default ToolbarGroupCollapsed; diff --git a/packages/components/src/toolbar-group/toolbar-group-container.js b/packages/components/src/toolbar-group/toolbar-group-container.js new file mode 100644 index 00000000000000..d86e066ce45a11 --- /dev/null +++ b/packages/components/src/toolbar-group/toolbar-group-container.js @@ -0,0 +1,6 @@ +const ToolbarGroupContainer = ( props ) => ( +
+ { props.children } +
+); +export default ToolbarGroupContainer; diff --git a/packages/components/src/toolbar-group/toolbar-group-container.native.js b/packages/components/src/toolbar-group/toolbar-group-container.native.js new file mode 100644 index 00000000000000..705c6b00806c38 --- /dev/null +++ b/packages/components/src/toolbar-group/toolbar-group-container.native.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { withPreferredColorScheme } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +const ToolbarGroupContainer = ( { getStylesFromColorScheme, passedStyle, children } ) => ( + + { children } + +); + +export default withPreferredColorScheme( ToolbarGroupContainer ); diff --git a/packages/components/src/toolbar/index.js b/packages/components/src/toolbar/index.js index bf9237eed1b624..9d37a768cadc70 100644 --- a/packages/components/src/toolbar/index.js +++ b/packages/components/src/toolbar/index.js @@ -2,86 +2,34 @@ * External dependencies */ import classnames from 'classnames'; -import { flatMap } from 'lodash'; /** * Internal dependencies */ -import ToolbarButton from '../toolbar-button'; -import DropdownMenu from '../dropdown-menu'; +import ToolbarGroup from '../toolbar-group'; import ToolbarContainer from './toolbar-container'; /** - * Renders a toolbar with controls. + * Renders a toolbar. * - * The `controls` prop accepts an array of sets. A set is an array of controls. - * Controls have the following shape: + * To add controls, simply pass `ToolbarButton` components as children. * - * ``` - * { - * icon: string, - * title: string, - * subscript: string, - * onClick: Function, - * isActive: boolean, - * isDisabled: boolean - * } - * ``` - * - * For convenience it is also possible to pass only an array of controls. It is - * then assumed this is the only set. - * - * Either `controls` or `children` is required, otherwise this components - * renders nothing. - * - * @param {Object} props - * @param {Array} [props.controls] The controls to render in this toolbar. - * @param {WPElement} [props.children] Any other things to render inside the - * toolbar besides the controls. - * @param {string} [props.className] Class to set on the container div. - * - * @return {WPComponent} The rendered component. + * @param {Object} props Component props. + * @param {string} [props.className] Class to set on the container div. */ -function Toolbar( { controls = [], children, className, isCollapsed, icon, label, ...otherProps } ) { - if ( - ( ! controls || ! controls.length ) && - ! children - ) { - return null; - } - - // Normalize controls to nested array of objects (sets of controls) - let controlSets = controls; - if ( ! Array.isArray( controlSets[ 0 ] ) ) { - controlSets = [ controlSets ]; - } - - if ( isCollapsed ) { +function Toolbar( { className, __experimentalAccessibilityLabel, ...props } ) { + if ( __experimentalAccessibilityLabel ) { return ( - ); } - return ( - - { flatMap( controlSets, ( controlSet, indexOfSet ) => ( - controlSet.map( ( control, indexOfControl ) => ( - 0 && indexOfControl === 0 ? 'has-left-divider' : null } - { ...control } - /> - ) ) - ) ) } - { children } - - ); + return ; } export default Toolbar; diff --git a/packages/components/src/toolbar/stories/index.js b/packages/components/src/toolbar/stories/index.js new file mode 100644 index 00000000000000..102f924f5a1a07 --- /dev/null +++ b/packages/components/src/toolbar/stories/index.js @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import Toolbar from '../'; +import { SVG, Path, ToolbarButton, ToolbarGroup } from '../../'; + +export default { title: 'Components|Toolbar', component: Toolbar }; + +function InlineImageIcon() { + return ( + + + + ); +} + +/* eslint-disable no-restricted-syntax */ +export const _default = () => { + return ( + // id is required for server side rendering + + + + + + + + + + , title: 'Inline image' }, + { icon: 'editor-strikethrough', title: 'Strikethrough' }, + ] } + /> + + + + ); +}; + +export const withoutGroup = () => { + return ( + // id is required for server side rendering + + + + + + ); +}; +/* eslint-enable no-restricted-syntax */ diff --git a/packages/components/src/toolbar/style.scss b/packages/components/src/toolbar/style.scss index f073721663d00a..2dc89b9b4e5f8f 100644 --- a/packages/components/src/toolbar/style.scss +++ b/packages/components/src/toolbar/style.scss @@ -1,8 +1,4 @@ -.components-toolbar { - margin: 0; - border: $border-width solid $light-gray-500; - background-color: $white; - +.components-accessible-toolbar { // Required for IE11. display: inline-flex; @@ -13,37 +9,3 @@ flex-shrink: 0; } - -div.components-toolbar { - & > div { - // IE11 does not support `position: sticky`, or Flex very well, so use block. - display: block; - @supports (position: sticky) { - display: flex; - } - - margin: 0; - } - - & > div + div { - margin-left: -3px; - - &.has-left-divider { - margin-left: 6px; - position: relative; - overflow: visible; - } - - &.has-left-divider::before { - display: inline-block; - content: ""; - box-sizing: content-box; - background-color: $light-gray-500; - position: absolute; - top: 8px; - left: -3px; - width: 1px; - height: $icon-button-size - 16px; - } - } -} diff --git a/packages/components/src/toolbar/test/index.js b/packages/components/src/toolbar/test/index.js index 6abcad9c843440..2d454d1a8bff38 100644 --- a/packages/components/src/toolbar/test/index.js +++ b/packages/components/src/toolbar/test/index.js @@ -1,26 +1,42 @@ /** * External dependencies */ -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; /** * Internal dependencies */ import Toolbar from '../'; +import ToolbarButton from '../../toolbar-button'; describe( 'Toolbar', () => { describe( 'basic rendering', () => { + it( 'should render a toolbar with toolbar buttons', () => { + const wrapper = mount( + + + + + ); + const control1 = wrapper.find( 'button[aria-label="control1"]' ); + const control2 = wrapper.find( 'button[aria-label="control1"]' ); + expect( control1 ).toHaveLength( 1 ); + expect( control2 ).toHaveLength( 1 ); + } ); + } ); + + describe( 'ToolbarGroup', () => { it( 'should render an empty node, when controls are not passed', () => { - const toolbar = shallow( ); - expect( toolbar.type() ).toBeNull(); + const wrapper = mount( ); + expect( wrapper.html() ).toBeNull(); } ); it( 'should render an empty node, when controls are empty', () => { - const toolbar = shallow( ); - expect( toolbar.type() ).toBeNull(); + const wrapper = mount( ); + expect( wrapper.html() ).toBeNull(); } ); - it( 'should render a list of controls with ToolbarButtons', () => { + it( 'should render a list of controls with buttons', () => { const clickHandler = ( event ) => event; const controls = [ { @@ -31,80 +47,14 @@ describe( 'Toolbar', () => { isActive: false, }, ]; - const toolbar = shallow( ); - const listItem = toolbar.find( 'ToolbarButton' ); - expect( listItem.props() ).toMatchObject( { - containerClassName: null, - icon: 'wordpress', - title: 'WordPress', - subscript: 'wp', - onClick: clickHandler, - isActive: false, - } ); - } ); - - it( 'should render a list of controls with ToolbarButtons and active control', () => { - const clickHandler = ( event ) => event; - const controls = [ - { - icon: 'wordpress', - title: 'WordPress', - subscript: 'wp', - onClick: clickHandler, - isActive: true, - }, - ]; - const toolbar = shallow( ); - const listItem = toolbar.find( 'ToolbarButton' ); - expect( listItem.props() ).toMatchObject( { - containerClassName: null, - icon: 'wordpress', - title: 'WordPress', - subscript: 'wp', - onClick: clickHandler, - isActive: true, + const wrapper = mount( ); + const button = wrapper.find( '[aria-label="WordPress"]' ).hostNodes(); + expect( button.props() ).toMatchObject( { + 'aria-label': 'WordPress', + 'aria-pressed': false, + 'data-subscript': 'wp', + type: 'button', } ); } ); - - it( 'should render a nested list of controls with separator between', () => { - const controls = [ - [ // First set - { - icon: 'wordpress', - title: 'WordPress', - }, - ], - [ // Second set - { - icon: 'wordpress', - title: 'WordPress', - }, - ], - ]; - - const toolbar = shallow( ); - expect( toolbar.children() ).toHaveLength( 2 ); - expect( toolbar.childAt( 0 ).prop( 'containerClassName' ) ).toBeNull(); - expect( toolbar.childAt( 1 ).prop( 'containerClassName' ) ).toBe( 'has-left-divider' ); - } ); - - it( 'should call the clickHandler on click.', () => { - const clickHandler = jest.fn(); - const event = { stopPropagation: () => undefined }; - const controls = [ - { - icon: 'wordpress', - title: 'WordPress', - subscript: 'wp', - onClick: clickHandler, - isActive: true, - }, - ]; - const toolbar = shallow( ); - const listItem = toolbar.find( 'ToolbarButton' ); - listItem.simulate( 'click', event ); - expect( clickHandler ).toHaveBeenCalledTimes( 1 ); - expect( clickHandler ).toHaveBeenCalledWith( event ); - } ); } ); } ); diff --git a/packages/components/src/toolbar/toolbar-container.js b/packages/components/src/toolbar/toolbar-container.js index 9361c1fcf0bbbb..d40843ddab3f3f 100644 --- a/packages/components/src/toolbar/toolbar-container.js +++ b/packages/components/src/toolbar/toolbar-container.js @@ -1,6 +1,33 @@ -const ToolbarContainer = ( props ) => ( -
- { props.children } -
-); -export default ToolbarContainer; +/** + * External dependencies + */ +import { useToolbarState, Toolbar } from 'reakit/Toolbar'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ToolbarContext from '../toolbar-context'; + +function ToolbarContainer( { accessibilityLabel, ...props }, ref ) { + // https://reakit.io/docs/basic-concepts/#state-hooks + const toolbarState = useToolbarState( { loop: true } ); + + return ( + // This will provide state for `ToolbarButton`'s + + + + ); +} + +export default forwardRef( ToolbarContainer ); diff --git a/packages/components/src/toolbar/toolbar-container.native.js b/packages/components/src/toolbar/toolbar-container.native.js index 33fe77d11db4c0..75a8a45ebb92ca 100644 --- a/packages/components/src/toolbar/toolbar-container.native.js +++ b/packages/components/src/toolbar/toolbar-container.native.js @@ -3,20 +3,8 @@ */ import { View } from 'react-native'; -/** - * WordPress dependencies - */ -import { withPreferredColorScheme } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import styles from './style.scss'; - -const ToolbarContainer = ( { getStylesFromColorScheme, passedStyle, children } ) => ( - - { children } - +const ToolbarContainer = ( { children } ) => ( + { children } ); -export default withPreferredColorScheme( ToolbarContainer ); +export default ToolbarContainer;