Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions components/higher-order/with-focus-outside/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,47 @@
/**
* External dependencies
*/
import { includes } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';

/**
* 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;
}

function withFocusOutside( WrappedComponent ) {
return class extends Component {
constructor() {
Expand All @@ -11,6 +50,7 @@ function withFocusOutside( WrappedComponent ) {
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() {
Expand All @@ -31,6 +71,11 @@ function withFocusOutside( WrappedComponent ) {
// 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 );
Expand All @@ -42,17 +87,49 @@ function withFocusOutside( WrappedComponent ) {
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 (
<div
onFocus={ this.cancelBlurCheck }
onMouseDown={ this.normalizeButtonFocus }
onMouseUp={ this.normalizeButtonFocus }
onTouchStart={ this.normalizeButtonFocus }
onTouchEnd={ this.normalizeButtonFocus }
onBlur={ this.queueBlurCheck }
>
<WrappedComponent
ref={ this.bindNode }
{ ...this.props } />
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}
};
}
Expand Down
19 changes: 18 additions & 1 deletion components/higher-order/with-focus-outside/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe( 'withFocusOutside', () => {
return (
<div>
<input />
<input />
<input type="button" />
</div>
);
}
Expand All @@ -46,6 +46,23 @@ describe( 'withFocusOutside', () => {
expect( callback ).not.toHaveBeenCalled();
} );

it( 'should not call handler if focus transitions via click to button', () => {
const callback = jest.fn();
const wrapper = mount( <EnhancedComponent onFocusOutside={ callback } /> );

wrapper.find( 'input' ).at( 0 ).simulate( 'focus' );
wrapper.find( 'input' ).at( 1 ).simulate( 'mousedown' );
wrapper.find( 'input' ).at( 0 ).simulate( 'blur' );
// In most browsers, the input at index 1 would receive a focus event
// at this point, but this is not guaranteed, which is the intention of
// the normalization behavior tested here.
wrapper.find( 'input' ).at( 1 ).simulate( 'mouseup' );

jest.runAllTimers();

expect( callback ).not.toHaveBeenCalled();
} );

it( 'should call handler if focus doesn\'t shift to element within component', () => {
const callback = jest.fn();
const wrapper = mount( <EnhancedComponent onFocusOutside={ callback } /> );
Expand Down
17 changes: 11 additions & 6 deletions edit-post/assets/stylesheets/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,6 @@ body.gutenberg-editor-page {
.editor-block-list__block {
input,
textarea {
border-radius: 4px;
border-color: $light-gray-500;
font-family: $default-font;
font-size: $default-font-size;
padding: 6px 10px;

&::-webkit-input-placeholder {
color: $dark-gray-300;
}
Expand All @@ -147,3 +141,14 @@ body.gutenberg-editor-page {
}
}
}

.editor-block-list__block {
input,
textarea {
border-radius: 4px;
border-color: $light-gray-500;
font-family: $default-font;
font-size: $default-font-size;
padding: 6px 10px;
}
}
74 changes: 18 additions & 56 deletions editor/components/post-title/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { __ } from '@wordpress/i18n';
import { Component, compose } from '@wordpress/element';
import { keycodes } from '@wordpress/utils';
import { createBlock, getDefaultBlockName } from '@wordpress/blocks';
import { withContext } from '@wordpress/components';
import { withContext, withFocusOutside } from '@wordpress/components';

/**
* Internal dependencies
Expand All @@ -32,47 +32,18 @@ class PostTitle extends Component {
constructor() {
super( ...arguments );

this.bindContainer = this.bindNode.bind( this, 'container' );
this.bindTextarea = this.bindNode.bind( this, 'textarea' );
this.onChange = this.onChange.bind( this );
this.onSelect = this.onSelect.bind( this );
this.onUnselect = this.onUnselect.bind( this );
this.onSelectionChange = this.onSelectionChange.bind( this );
this.onKeyDown = this.onKeyDown.bind( this );
this.blurIfOutside = this.blurIfOutside.bind( this );

this.nodes = {};

this.state = {
isSelected: false,
};
}

componentDidMount() {
document.addEventListener( 'selectionchange', this.onSelectionChange );
}

componentWillUnmount() {
document.removeEventListener( 'selectionchange', this.onSelectionChange );
}

bindNode( name, node ) {
this.nodes[ name ] = node;
}

onSelectionChange() {
const textarea = this.nodes.textarea.textarea;
if (
document.activeElement === textarea &&
textarea.selectionStart !== textarea.selectionEnd
) {
this.onSelect();
}
}

onChange( event ) {
const newTitle = event.target.value.replace( REGEXP_NEWLINES, ' ' );
this.props.onUpdate( newTitle );
handleFocusOutside() {
this.onUnselect();
}

onSelect() {
Expand All @@ -84,10 +55,9 @@ class PostTitle extends Component {
this.setState( { isSelected: false } );
}

blurIfOutside( event ) {
if ( ! this.nodes.container.contains( event.relatedTarget ) ) {
this.onUnselect();
}
onChange( event ) {
const newTitle = event.target.value.replace( REGEXP_NEWLINES, ' ' );
this.props.onUpdate( newTitle );
}

onKeyDown( event ) {
Expand All @@ -103,26 +73,17 @@ class PostTitle extends Component {
const className = classnames( 'editor-post-title', { 'is-selected': isSelected } );

return (
<div
ref={ this.bindContainer }
onFocus={ this.onSelect }
onBlur={ this.blurIfOutside }
className={ className }
tabIndex={ -1 /* Necessary for Firefox to include relatedTarget in blur event */ }
>
<div className={ className }>
{ isSelected && <PostPermalink /> }
<div>
<Textarea
ref={ this.bindTextarea }
className="editor-post-title__input"
value={ title }
onChange={ this.onChange }
placeholder={ placeholder || __( 'Add title' ) }
onClick={ this.onSelect }
onKeyDown={ this.onKeyDown }
onKeyPress={ this.onUnselect }
/>
</div>
<Textarea
className="editor-post-title__input"
value={ title }
onChange={ this.onChange }
placeholder={ placeholder || __( 'Add title' ) }
onFocus={ this.onSelect }
onKeyDown={ this.onKeyDown }
onKeyPress={ this.onUnselect }
/>
</div>
);
}
Expand Down Expand Up @@ -151,5 +112,6 @@ const applyEditorSettings = withContext( 'editor' )(

export default compose(
applyConnect,
applyEditorSettings
applyEditorSettings,
withFocusOutside
)( PostTitle );
46 changes: 13 additions & 33 deletions editor/components/post-title/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,24 @@
position: relative;
padding: 5px 0;

div {
.editor-post-title__input {
display: block;
width: 100%;
padding: #{ $block-padding + 5px } $block-padding;
margin: 0;
box-shadow: none;
border: 1px solid transparent;
font-size: $editor-font-size;
transition: 0.2s outline;
margin-top: 0;
margin-bottom: 0;
padding: $block-padding;
}
font-size: 2em;
font-family: $editor-font;
line-height: $default-line-height;

&:hover div {
border: 1px solid $light-gray-500;
transition: 0.2s outline;
// inherited from h1
font-weight: 600;
}

&.is-selected div {
&.is-selected .editor-post-title__input,
.editor-post-title__input:focus {
border: 1px solid $light-gray-500;
transition: 0.2s outline;
}
}

.editor-post-title textarea.editor-post-title__input {
display: block;
font-size: 2em;
font-family: $editor-font;
line-height: $default-line-height;
outline: none;
border: none;
box-shadow: none;
width: 100%;
padding: 5px 0;
margin: 0;

// inherited from h1
font-weight: 600;

&:focus {
outline: none;
box-shadow: none;
}
}

Expand Down