Skip to content

Commit

Permalink
UI: Refactor PostTitle for easier select, deselect (#5422)
Browse files Browse the repository at this point in the history
* Chrome: Refactor PostTitle for easier select, deselect

* Title: Bind selected styles to is-selected parent modifier

Nature of withFocusOutside is that there'll be a short delay between focus being lost from textarea and handleFocusOutside being called, causing the permalink display and title selected effects from dismissing out of sync. While we _can_ style based on textarea focus, we'd rather ensure sync. Ideally, there'd not be a delay from `withFocusOutside`.

Also, we spend a fair bit of effort overriding styles from main.scss which shouldn't be applied to the title in the first place. Why do we assume there's a textarea element? This is abstracted into `Textarea` component, but we have no guarantee that's the tag rendered.

* Components: Add button focus normalization to withFocusOutside

* Components: Include anchor in withFocusOutside button normalization

Safari does not count clicks on anchors as focus

* Components: Include touch events in button focus normalization

See "Does tapping on a button give it the focus?" at: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
  • Loading branch information
aduth committed Mar 7, 2018
1 parent 4356638 commit fe83f67
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 96 deletions.
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

0 comments on commit fe83f67

Please sign in to comment.