diff --git a/packages/components/src/higher-order/with-focus-outside/index.native.js b/packages/components/src/higher-order/with-focus-outside/index.native.js new file mode 100644 index 0000000000000..7d3ae52eaeb78 --- /dev/null +++ b/packages/components/src/higher-order/with-focus-outside/index.native.js @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Input types which are classified as button types, for use in considering + * whether element is a (focus-normalized) button. + * + * @type {string[]} + */ +const INPUT_BUTTON_TYPES = [ + 'button', + 'submit', +]; + +/** + * Returns true if the given element is a button element subject to focus + * normalization, or false otherwise. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus + * + * @param {Element} element Element to test. + * + * @return {boolean} Whether element is a button. + */ +function isFocusNormalizedButton( element ) { + switch ( element.nodeName ) { + case 'A': + case 'BUTTON': + return true; + + case 'INPUT': + return includes( INPUT_BUTTON_TYPES, element.type ); + } + + return false; +} + +export default createHigherOrderComponent( + ( WrappedComponent ) => { + return class extends Component { + constructor() { + super( ...arguments ); + + this.bindNode = this.bindNode.bind( this ); + this.cancelBlurCheck = this.cancelBlurCheck.bind( this ); + this.queueBlurCheck = this.queueBlurCheck.bind( this ); + this.normalizeButtonFocus = this.normalizeButtonFocus.bind( this ); + } + + componentWillUnmount() { + this.cancelBlurCheck(); + } + + bindNode( node ) { + if ( node ) { + this.node = node; + } else { + delete this.node; + this.cancelBlurCheck(); + } + } + + queueBlurCheck( event ) { + // React does not allow using an event reference asynchronously + // due to recycling behavior, except when explicitly persisted. + event.persist(); + + // Skip blur check if clicking button. See `normalizeButtonFocus`. + if ( this.preventBlurCheck ) { + return; + } + + this.blurCheckTimeout = setTimeout( () => { + if ( 'function' === typeof this.node.handleFocusOutside ) { + this.node.handleFocusOutside( event ); + } + }, 0 ); + } + + cancelBlurCheck() { + clearTimeout( this.blurCheckTimeout ); + } + + /** + * Handles a mousedown or mouseup event to respectively assign and + * unassign a flag for preventing blur check on button elements. Some + * browsers, namely Firefox and Safari, do not emit a focus event on + * button elements when clicked, while others do. The logic here + * intends to normalize this as treating click on buttons as focus. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus + * + * @param {MouseEvent} event Event for mousedown or mouseup. + */ + normalizeButtonFocus( event ) { + const { type, target } = event; + + const isInteractionEnd = includes( [ 'mouseup', 'touchend' ], type ); + + if ( isInteractionEnd ) { + this.preventBlurCheck = false; + } else if ( isFocusNormalizedButton( target ) ) { + this.preventBlurCheck = true; + } + } + + render() { + // Disable reason: See `normalizeButtonFocus` for browser-specific + // focus event normalization. + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + + + + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } + }; + }, 'withFocusOutside' +); diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index 3e44bdec61e50..cbaae28e089bd 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -9,3 +9,4 @@ export { createSlotFill, Slot, Fill, Provider as SlotFillProvider } from './slot // Higher-Order Components export { default as withFilters } from './higher-order/with-filters'; +export { default as withFocusOutside } from './higher-order/with-focus-outside'; diff --git a/packages/editor/src/components/index.native.js b/packages/editor/src/components/index.native.js index 229efa5879cc3..d226b4fe46480 100644 --- a/packages/editor/src/components/index.native.js +++ b/packages/editor/src/components/index.native.js @@ -7,5 +7,6 @@ export { default as BlockFormatControls } from './block-format-controls'; export { default as BlockControls } from './block-controls'; export { default as BlockEdit } from './block-edit'; export { default as DefaultBlockAppender } from './default-block-appender'; +export { default as PostTitle } from './post-title'; export { default as EditorHistoryRedo } from './editor-history/redo'; export { default as EditorHistoryUndo } from './editor-history/undo'; diff --git a/packages/editor/src/components/post-title/index.native.js b/packages/editor/src/components/post-title/index.native.js new file mode 100644 index 0000000000000..61148e0f4e7a6 --- /dev/null +++ b/packages/editor/src/components/post-title/index.native.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { TextInput } from 'react-native'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import { withDispatch } from '@wordpress/data'; +import { withFocusOutside } from '@wordpress/components'; +import { withInstanceId, compose } from '@wordpress/compose'; + +class PostTitle extends Component { + constructor() { + super( ...arguments ); + + this.onChange = this.onChange.bind( this ); + this.onSelect = this.onSelect.bind( this ); + this.onUnselect = this.onUnselect.bind( this ); + + this.state = { + isSelected: false, + }; + } + + handleFocusOutside() { + this.onUnselect(); + } + + onSelect() { + this.setState( { isSelected: true } ); + this.props.clearSelectedBlock(); + } + + onUnselect() { + this.setState( { isSelected: false } ); + } + + onChange( title ) { + this.props.onUpdate( title ); + } + + render() { + const { + placeholder, + style, + title, + } = this.props; + + const decodedPlaceholder = decodeEntities( placeholder ); + + return ( + + + ); + } +} + +const applyWithDispatch = withDispatch( ( dispatch ) => { + const { + clearSelectedBlock, + } = dispatch( 'core/editor' ); + + return { + clearSelectedBlock, + }; +} ); + +export default compose( + applyWithDispatch, + withInstanceId, + withFocusOutside +)( PostTitle );