From 8313c650f7d1bdb5e3dc893888e467afed4da329 Mon Sep 17 00:00:00 2001 From: Luke Walczak Date: Thu, 6 Feb 2020 16:03:46 +0100 Subject: [PATCH] [RNMobile] Button Block (#18823) * Initial work on Button block * Some button styling * Do some tweaks on android * Some styling and connect link settings * Support borderRadius * Adjust to dark mode * Add a RichText wrapper with LinearGradient * Extract RichText wrapper * Update test setup.js * Refactor border width * Set max and min width for ios RichText * Add comments and set max width when screen has horizontal orientation * Extract NotificationSheet to separate file * Move NotifcationSheet to components * Set isFocused on true by default * fix jumping height android * Improve a11y and small refactor * Move variables to styles * Update setting background color * Changes in NotificationSheet and Settings * Rewrite mobile button block to class component * Rewrite functions * Use isFocused() method * Rename files * Create separate settings for mobile Button * Remove notification bottom sheet * Rename component and comment logic * Replace MissingControl in favor of UnsupportedFooterControl * Adjust link modal for Button block purposes * Small cleanup * Fix test * Try to fix mobile test * Adjust button styling after uploading the latest changes * Replace Dashicon in favor of Icon in native cell component * Fix overriding existing URL in bottom sheet * Making handlers more consistent * Revert changes in link settings and build custom adjusted for Button component * Fix test * Correct condition * Correct focusing RichText within Button * Unify isButtonFocused for both platforms * Fix lint issues after merge * Reuse controls * Adjust reused components and add caret color --- .../src/components/index.native.js | 1 + .../src/button/color-background.native.js | 65 +++ .../block-library/src/button/edit.native.js | 425 ++++++++++++++++++ .../src/button/editor.native.scss | 32 ++ .../block-library/src/button/index.native.js | 11 + .../src/button/link-rel.native.js | 10 + .../src/button/rich-text.android.scss | 6 + .../src/button/rich-text.ios.scss | 4 + packages/block-library/src/index.native.js | 1 + packages/components/src/index.native.js | 1 + .../src/mobile/bottom-sheet/cell.native.js | 4 +- .../src/mobile/bottom-sheet/index.native.js | 3 + .../mobile/bottom-sheet/styles.native.scss | 7 + .../unsupported-footer-cell.native.js | 25 ++ .../components/src/panel/body.native.scss | 2 +- .../index.native.js | 27 ++ .../rich-text/src/component/index.native.js | 21 +- test/native/setup.js | 4 + 18 files changed, 644 insertions(+), 5 deletions(-) create mode 100644 packages/block-library/src/button/color-background.native.js create mode 100644 packages/block-library/src/button/edit.native.js create mode 100644 packages/block-library/src/button/editor.native.scss create mode 100644 packages/block-library/src/button/index.native.js create mode 100644 packages/block-library/src/button/link-rel.native.js create mode 100644 packages/block-library/src/button/rich-text.android.scss create mode 100644 packages/block-library/src/button/rich-text.ios.scss create mode 100644 packages/components/src/mobile/bottom-sheet/unsupported-footer-cell.native.js create mode 100644 packages/components/src/unsupported-footer-control/index.native.js diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index 0227ceabcc6470..9fe95c7d93cd25 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -6,6 +6,7 @@ export { default as BlockFormatControls } from './block-format-controls'; export { default as BlockIcon } from './block-icon'; export { default as BlockVerticalAlignmentToolbar } from './block-vertical-alignment-toolbar'; export * from './colors'; +export * from './gradients'; export * from './font-sizes'; export { default as AlignmentToolbar } from './alignment-toolbar'; export { default as InnerBlocks } from './inner-blocks'; diff --git a/packages/block-library/src/button/color-background.native.js b/packages/block-library/src/button/color-background.native.js new file mode 100644 index 00000000000000..16cdffd2ac3b69 --- /dev/null +++ b/packages/block-library/src/button/color-background.native.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +/** + * WordPress dependencies + */ +import { __experimentalUseGradient } from '@wordpress/block-editor'; +/** + * Internal dependencies + */ +import styles from './editor.scss'; + +function ColorBackground( { children, borderRadiusValue, backgroundColor } ) { + const wrapperStyles = [ + styles.richTextWrapper, + { + borderRadius: borderRadiusValue, + backgroundColor, + }, + ]; + + const { gradientValue } = __experimentalUseGradient(); + + function transformGradient() { + const matchColorGroup = /(rgba|rgb|#)(.+?)[\%]/g; + const matchDeg = /(\d.+)deg/g; + + const colorGroup = gradientValue + .match( matchColorGroup ) + .map( ( color ) => color.split( ' ' ) ); + + const colors = colorGroup.map( ( color ) => color[ 0 ] ); + const locations = colorGroup.map( + ( location ) => Number( location[ 1 ].replace( '%', '' ) ) / 100 + ); + const angle = Number( matchDeg.exec( gradientValue )[ 1 ] ); + + return { + colors, + locations, + angle, + }; + } + + if ( gradientValue ) { + const { colors, locations, angle } = transformGradient(); + return ( + + { children } + + ); + } + return { children }; +} + +export default ColorBackground; diff --git a/packages/block-library/src/button/edit.native.js b/packages/block-library/src/button/edit.native.js new file mode 100644 index 00000000000000..f2aad941ff4faa --- /dev/null +++ b/packages/block-library/src/button/edit.native.js @@ -0,0 +1,425 @@ +/** + * External dependencies + */ +import { View, AccessibilityInfo, Platform, Clipboard } from 'react-native'; +/** + * WordPress dependencies + */ +import { withInstanceId, compose } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { + RichText, + withColors, + InspectorControls, + BlockControls, +} from '@wordpress/block-editor'; +import { + TextControl, + ToggleControl, + PanelBody, + RangeControl, + UnsupportedFooterControl, + ToolbarGroup, + ToolbarButton, + BottomSheet, +} from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; +import { isURL, prependHTTP } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import richTextStyle from './rich-text.scss'; +import styles from './editor.scss'; +import ColorBackground from './color-background.native'; +import LinkRelIcon from './link-rel'; + +const NEW_TAB_REL = 'noreferrer noopener'; +const MIN_BORDER_RADIUS_VALUE = 0; +const MAX_BORDER_RADIUS_VALUE = 50; +const INITIAL_MAX_WIDTH = 108; + +class ButtonEdit extends Component { + constructor( props ) { + super( props ); + this.onChangeText = this.onChangeText.bind( this ); + this.onChangeBorderRadius = this.onChangeBorderRadius.bind( this ); + this.onChangeLinkRel = this.onChangeLinkRel.bind( this ); + this.onChangeOpenInNewTab = this.onChangeOpenInNewTab.bind( this ); + this.onChangeURL = this.onChangeURL.bind( this ); + this.onClearSettings = this.onClearSettings.bind( this ); + this.onLayout = this.onLayout.bind( this ); + this.getURLFromClipboard = this.getURLFromClipboard.bind( this ); + this.onToggleLinkSettings = this.onToggleLinkSettings.bind( this ); + this.onToggleButtonFocus = this.onToggleButtonFocus.bind( this ); + + // `isEditingURL` property is used to prevent from automatically pasting + // URL from clipboard while trying to clear `Button URL` field and then + // manually adding specific link + this.isEditingURL = false; + + this.state = { + maxWidth: INITIAL_MAX_WIDTH, + isLinkSheetVisible: false, + isButtonFocused: true, + }; + } + + componentDidUpdate( prevProps, prevState ) { + const { + selectedId, + setAttributes, + editorSidebarOpened, + attributes: { url }, + } = this.props; + const { isLinkSheetVisible, isButtonFocused } = this.state; + + // Get initial value for `isEditingURL` when closing link settings sheet or button settings sheet + if ( + ( prevProps.editorSidebarOpened && ! editorSidebarOpened ) || + ( prevState.isLinkSheetVisible && ! isLinkSheetVisible ) + ) { + this.isEditingURL = false; + } + + // Blur `RichText` on Android when link settings sheet or button settings sheet is opened, + // to avoid flashing caret after closing one of them + if ( + ( ! prevProps.editorSidebarOpened && editorSidebarOpened ) || + ( ! prevState.isLinkSheetVisible && isLinkSheetVisible ) + ) { + if ( Platform.OS === 'android' && this.richTextRef ) { + this.richTextRef.blur(); + this.onToggleButtonFocus( false ); + } + } + + // Paste a URL from clipboard + if ( isLinkSheetVisible && ! url && ! this.isEditingURL ) { + this.getURLFromClipboard(); + } + + // Prepends "http://" to a url when closing link settings sheet and button settings sheet + if ( ! isLinkSheetVisible && ! editorSidebarOpened ) { + setAttributes( { url: prependHTTP( url ) } ); + } + + if ( this.richTextRef ) { + const selectedRichText = this.richTextRef.props.id === selectedId; + + if ( ! selectedRichText && isButtonFocused ) { + this.onToggleButtonFocus( false ); + } + + if ( + selectedRichText && + selectedId !== prevProps.selectedId && + ! isButtonFocused + ) { + AccessibilityInfo.isScreenReaderEnabled().then( ( enabled ) => { + if ( enabled ) { + this.onToggleButtonFocus( true ); + this.richTextRef.focus(); + } + } ); + } + } + } + + async getURLFromClipboard() { + const { setAttributes } = this.props; + const clipboardText = await Clipboard.getString(); + + if ( ! clipboardText ) { + return; + } + // Check if pasted text is URL + if ( ! isURL( clipboardText ) ) { + return; + } + + setAttributes( { url: clipboardText } ); + } + + getBackgroundColor() { + const { backgroundColor, attributes } = this.props; + if ( backgroundColor.color ) { + // `backgroundColor` which should be set when we are able to resolve it + return backgroundColor.color; + } else if ( attributes.backgroundColor ) { + // `backgroundColor` which should be set when we can’t resolve + // the button `backgroundColor` that was created on web + return styles.fallbackButton.backgroundColor; + // `backgroundColor` which should be set when `Button` is created on mobile + } + return styles.button.backgroundColor; + } + + onChangeText( value ) { + const { setAttributes } = this.props; + setAttributes( { text: value } ); + } + + onChangeBorderRadius( value ) { + const { setAttributes } = this.props; + setAttributes( { + borderRadius: value, + } ); + } + + onChangeLinkRel( value ) { + const { setAttributes } = this.props; + setAttributes( { rel: value } ); + } + + onChangeURL( value ) { + this.isEditingURL = true; + const { setAttributes } = this.props; + setAttributes( { url: value } ); + } + + onLayout( { nativeEvent } ) { + const { width } = nativeEvent.layout; + const { marginRight, paddingRight, borderWidth } = styles.button; + const buttonSpacing = 2 * ( marginRight + paddingRight + borderWidth ); + this.setState( { maxWidth: width - buttonSpacing } ); + } + + onChangeOpenInNewTab( value ) { + const { setAttributes, attributes } = this.props; + const { rel } = attributes; + + const newLinkTarget = value ? '_blank' : undefined; + + let updatedRel = rel; + if ( newLinkTarget && ! rel ) { + updatedRel = NEW_TAB_REL; + } else if ( ! newLinkTarget && rel === NEW_TAB_REL ) { + updatedRel = undefined; + } + + setAttributes( { + linkTarget: newLinkTarget, + rel: updatedRel, + } ); + } + + onToggleLinkSettings() { + const { isLinkSheetVisible } = this.state; + this.setState( { isLinkSheetVisible: ! isLinkSheetVisible } ); + } + + onToggleButtonFocus( value ) { + this.setState( { isButtonFocused: value } ); + } + + onClearSettings() { + const { setAttributes } = this.props; + + setAttributes( { + url: '', + rel: '', + linkTarget: '', + } ); + + this.setState( { isLinkSheetVisible: false } ); + } + + getLinkSettings( url, rel, linkTarget, isCompatibleWithSettings ) { + return ( + <> + + + + + ); + } + + render() { + const { attributes, textColor, isSelected, clientId } = this.props; + const { + placeholder, + text, + borderRadius, + url, + linkTarget, + rel, + } = attributes; + const { maxWidth, isLinkSheetVisible, isButtonFocused } = this.state; + + const borderRadiusValue = + borderRadius !== undefined + ? borderRadius + : styles.button.borderRadius; + const outlineBorderRadius = + borderRadiusValue > 0 + ? borderRadiusValue + + styles.button.paddingTop + + styles.button.borderWidth + : 0; + + // To achieve proper expanding and shrinking `RichText` on iOS, there is a need to set a `minWidth` + // value at least on 1 when `RichText` is focused or when is not focused, but `RichText` value is + // different than empty string. + const minWidth = + isButtonFocused || ( ! isButtonFocused && text && text !== '' ) + ? 1 + : styles.button.minWidth; + // To achieve proper expanding and shrinking `RichText` on Android, there is a need to set + // a `placeholder` as an empty string when `RichText` is focused, + // because `AztecView` is calculating a `minWidth` based on placeholder text. + const placeholderText = + isButtonFocused || ( ! isButtonFocused && text && text !== '' ) + ? '' + : placeholder || __( 'Add text…' ); + + return ( + + + + { + this.richTextRef = richText; + } } + placeholder={ placeholderText } + value={ text } + onChange={ this.onChangeText } + style={ { + ...richTextStyle.richText, + color: textColor.color || '#fff', + } } + textAlign="center" + placeholderTextColor={ 'lightgray' } + identifier="content" + tagName="p" + minWidth={ minWidth } + maxWidth={ maxWidth } + id={ clientId } + isSelected={ isButtonFocused } + withoutInteractiveFormatting + unstableOnFocus={ () => + this.onToggleButtonFocus( true ) + } + __unstableMobileNoFocusOnMount={ ! isSelected } + selectionColor={ textColor.color || '#fff' } + /> + + + { isButtonFocused && ( + + + + + + ) } + + + { this.getLinkSettings( url, rel, linkTarget ) } + + + + + + + + + { this.getLinkSettings( + url, + rel, + linkTarget, + true + ) } + + + + + + + + ); + } +} + +export default compose( [ + withInstanceId, + withColors( 'backgroundColor', { textColor: 'color' } ), + withSelect( ( select ) => { + const { isEditorSidebarOpened } = select( 'core/edit-post' ); + const { getSelectedBlockClientId } = select( 'core/block-editor' ); + + const selectedId = getSelectedBlockClientId(); + + return { + selectedId, + editorSidebarOpened: isEditorSidebarOpened(), + }; + } ), +] )( ButtonEdit ); diff --git a/packages/block-library/src/button/editor.native.scss b/packages/block-library/src/button/editor.native.scss new file mode 100644 index 00000000000000..d20572befd5650 --- /dev/null +++ b/packages/block-library/src/button/editor.native.scss @@ -0,0 +1,32 @@ +.richTextWrapper { + overflow: hidden; + align-self: flex-start; +} + +.container { + padding: $block-spacing; + background-color: transparent; + align-self: flex-start; +} + +.outlineBorder { + border-width: $border-width; +} + +.button { + background-color: $button-default-bg; + border-width: $border-width; + border-radius: $border-width * 4; + padding: $block-spacing; + max-width: 580px; + min-width: 108px; + margin: $panel-padding; +} + +.fallbackButton { + background-color: $button-fallback-bg; +} + +.clearLinkButton { + color: $alert-red; +} diff --git a/packages/block-library/src/button/index.native.js b/packages/block-library/src/button/index.native.js new file mode 100644 index 00000000000000..b3a3b7c131dfe1 --- /dev/null +++ b/packages/block-library/src/button/index.native.js @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import { settings as webSettings } from './index.js'; + +export { metadata, name } from './index.js'; + +export const settings = { + ...webSettings, + parent: undefined, +}; diff --git a/packages/block-library/src/button/link-rel.native.js b/packages/block-library/src/button/link-rel.native.js new file mode 100644 index 00000000000000..bc8060ec405a88 --- /dev/null +++ b/packages/block-library/src/button/link-rel.native.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( + + + +); diff --git a/packages/block-library/src/button/rich-text.android.scss b/packages/block-library/src/button/rich-text.android.scss new file mode 100644 index 00000000000000..47f6d2e2ae8cbf --- /dev/null +++ b/packages/block-library/src/button/rich-text.android.scss @@ -0,0 +1,6 @@ +.richText { + background-color: transparent; + padding-left: $panel-padding; + padding-right: $panel-padding; + margin: 10px $panel-padding; +} diff --git a/packages/block-library/src/button/rich-text.ios.scss b/packages/block-library/src/button/rich-text.ios.scss new file mode 100644 index 00000000000000..0c7d60695ba765 --- /dev/null +++ b/packages/block-library/src/button/rich-text.ios.scss @@ -0,0 +1,4 @@ +.richText { + margin: 10px $panel-padding; + background-color: transparent; +} diff --git a/packages/block-library/src/index.native.js b/packages/block-library/src/index.native.js index 3a8c91213178eb..4d2a5f9e7b17ab 100644 --- a/packages/block-library/src/index.native.js +++ b/packages/block-library/src/index.native.js @@ -149,6 +149,7 @@ export const registerCoreBlocks = () => { preformatted, gallery, group, + devOnly( button ), spacer, shortcode, ].forEach( registerBlock ); diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index 624033f1cbead8..4b3509f2e4eada 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -34,6 +34,7 @@ export { default as TextControl } from './text-control'; export { default as ToggleControl } from './toggle-control'; export { default as SelectControl } from './select-control'; export { default as RangeControl } from './range-control'; +export { default as UnsupportedFooterControl } from './unsupported-footer-control'; // Higher-Order Components export { default as withConstrainedTabbing } from './higher-order/with-constrained-tabbing'; diff --git a/packages/components/src/mobile/bottom-sheet/cell.native.js b/packages/components/src/mobile/bottom-sheet/cell.native.js index ad4c601ab971a7..e1bea1ac3bea34 100644 --- a/packages/components/src/mobile/bottom-sheet/cell.native.js +++ b/packages/components/src/mobile/bottom-sheet/cell.native.js @@ -14,7 +14,7 @@ import { isEmpty } from 'lodash'; /** * WordPress dependencies */ -import { Dashicon } from '@wordpress/components'; +import { Icon } from '@wordpress/components'; import { Component } from '@wordpress/element'; import { __, _x, sprintf } from '@wordpress/i18n'; import { withPreferredColorScheme } from '@wordpress/compose'; @@ -296,7 +296,7 @@ class BottomSheetCell extends Component { { icon && ( - + ); +} + +export default withPreferredColorScheme( UnsupportedFooterCell ); diff --git a/packages/components/src/panel/body.native.scss b/packages/components/src/panel/body.native.scss index a30c4df4c15dfc..520c23a220ab56 100644 --- a/packages/components/src/panel/body.native.scss +++ b/packages/components/src/panel/body.native.scss @@ -3,7 +3,7 @@ } .sectionHeaderText { - color: #87a6bc; + color: $gray; padding-top: 24; padding-bottom: 8; font-size: 14; diff --git a/packages/components/src/unsupported-footer-control/index.native.js b/packages/components/src/unsupported-footer-control/index.native.js new file mode 100644 index 00000000000000..26985dd58a6816 --- /dev/null +++ b/packages/components/src/unsupported-footer-control/index.native.js @@ -0,0 +1,27 @@ +/** + * Internal dependencies + */ +import UnsupportedFooterCell from '../mobile/bottom-sheet/unsupported-footer-cell'; + +function UnsupportedFooterControl( { + label, + help, + instanceId, + className, + ...props +} ) { + const id = `inspector-unsupported-footer-control-${ instanceId }`; + + return ( + + ); +} + +export default UnsupportedFooterControl; diff --git a/packages/rich-text/src/component/index.native.js b/packages/rich-text/src/component/index.native.js index ae606cd7c248eb..80c2471511fbf7 100644 --- a/packages/rich-text/src/component/index.native.js +++ b/packages/rich-text/src/component/index.native.js @@ -278,8 +278,7 @@ export class RichText extends Component { * Handles any case where the content of the AztecRN instance has changed in size */ onContentSizeChange( contentSize ) { - const contentHeight = contentSize.height; - this.setState( { height: contentHeight } ); + this.setState( contentSize ); this.lastAztecEventType = 'content size change'; } @@ -696,7 +695,10 @@ export class RichText extends Component { __unstableIsSelected: isSelected, children, getStylesFromColorScheme, + minWidth, + maxWidth, formatTypes, + withoutInteractiveFormatting, } = this.props; const record = this.getRecord(); @@ -780,6 +782,12 @@ export class RichText extends Component { this.firedAfterTextChanged = false; } + // Logic below assures that `RichText` width will always have equal value when container is almost fully filled. + const width = + maxWidth && this.state.width && maxWidth - this.state.width < 10 + ? maxWidth + : this.state.width; + return ( { children && @@ -799,6 +807,9 @@ export class RichText extends Component { } } style={ { ...style, + ...( this.isIOS && minWidth && maxWidth + ? { width } + : {} ), minHeight: this.state.height, } } text={ { @@ -837,12 +848,18 @@ export class RichText extends Component { disableEditingMenu={ this.props.disableEditingMenu } isMultiline={ this.isMultiline } textAlign={ this.props.textAlign } + { ...( this.isIOS ? { maxWidth } : {} ) } + minWidth={ minWidth } + id={ this.props.id } selectionColor={ this.props.selectionColor } /> { isSelected && ( {} } /> diff --git a/test/native/setup.js b/test/native/setup.js index a901ea63cbef13..1a29cb054f9242 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -79,6 +79,10 @@ if ( ! global.window.matchMedia ) { } ); } +jest.mock( 'react-native-linear-gradient', () => () => 'LinearGradient', { + virtual: true, +} ); + // Overwrite some native module mocks from `react-native` jest preset: // https://github.com/facebook/react-native/blob/master/jest/setup.js // to fix issue "TypeError: Cannot read property 'Commands' of undefined"