-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Framework: Extract arrow navigation to its own component #2424
Changes from 9 commits
d2aedef
630f0e8
291a62a
401c4f1
30e13c1
e1475bf
adf550d
8b2e9ca
d89a857
3216e78
f3448c4
14b7a7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -104,6 +104,12 @@ class UrlInput extends Component { | |
|
||
onKeyDown( event ) { | ||
const { selectedSuggestion, posts } = this.state; | ||
// If the suggestions are not shown, we shouldn't handle the arrow keys | ||
// We shouldn't preventDefault to allow block arrow keys navigation | ||
if ( ! this.state.showSuggestions || ! this.state.posts.length ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we have a comment here to explain what this is for? I don't know what showSuggestions has to do with the key event. |
||
return false; | ||
} | ||
|
||
switch ( event.keyCode ) { | ||
case UP: { | ||
event.stopPropagation(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
/** | ||
* Check whether the selection touches an edge of the container | ||
* | ||
* @param {Element} container DOM Element | ||
* @param {Boolean} start Reverse means check if it touches the start of the container | ||
* @return {Boolean} Is Edge or not | ||
*/ | ||
export function isEdge( container, start = false ) { | ||
if ( [ 'INPUT', 'TEXTAREA' ].indexOf( container.tagName ) !== -1 ) { | ||
if ( container.selectionStart !== container.selectionEnd ) { | ||
return false; | ||
} | ||
|
||
if ( start ) { | ||
return container.selectionStart === 0; | ||
} | ||
|
||
return container.value.length === container.selectionStart; | ||
} | ||
|
||
if ( ! container.isContentEditable ) { | ||
return true; | ||
} | ||
|
||
const selection = window.getSelection(); | ||
const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; | ||
const position = start ? 'start' : 'end'; | ||
const order = start ? 'first' : 'last'; | ||
const offset = range[ `${ position }Offset` ]; | ||
|
||
let node = range.startContainer; | ||
|
||
if ( ! range || ! range.collapsed ) { | ||
return false; | ||
} | ||
|
||
if ( start && offset !== 0 ) { | ||
return false; | ||
} | ||
|
||
if ( ! start && offset !== node.textContent.length ) { | ||
return false; | ||
} | ||
|
||
while ( node !== container ) { | ||
const parentNode = node.parentNode; | ||
|
||
if ( parentNode[ `${ order }Child` ] !== node ) { | ||
return false; | ||
} | ||
|
||
node = parentNode; | ||
} | ||
|
||
return true; | ||
} | ||
|
||
/** | ||
* Places the caret at start or end of a given element | ||
* | ||
* @param {Element} container DOM Element | ||
* @param {Boolean} start Position: Start or end of the element | ||
*/ | ||
export function placeCaretAtEdge( container, start = false ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMHO, it would be better to use a hash for the options so it's more readable. (Same for the other functions.) |
||
const isInputOrTextarea = [ 'INPUT', 'TEXTAREA' ].indexOf( container.tagName ) !== -1; | ||
if ( isInputOrTextarea ) { | ||
container.focus(); | ||
setTimeout( () => { | ||
if ( start ) { | ||
container.selectionStart = 0; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we know that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it was necessary at the time of writing (not sure why?), but I made some tweaks later, I'll check again. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, the timeout is not necessary anymore :) |
||
container.selectionEnd = 0; | ||
} else { | ||
container.selectionStart = container.value.length; | ||
container.selectionEnd = container.value.length; | ||
} | ||
} ); | ||
return; | ||
} | ||
|
||
function placeCaretInContentEditable( element, atStart ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we create a function for this when we could inline it in place? Comments can serve as stand-in to self-documenting functions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep probably not necessary |
||
const range = document.createRange(); | ||
range.selectNodeContents( element ); | ||
range.collapse( atStart ); | ||
const sel = window.getSelection(); | ||
sel.removeAllRanges(); | ||
sel.addRange( range ); | ||
element.focus(); | ||
} | ||
|
||
placeCaretInContentEditable( container, start ); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
/** | ||
* Internal dependencies | ||
*/ | ||
import { isEdge, placeCaretAtEdge } from '../dom'; | ||
|
||
jest.useFakeTimers(); | ||
|
||
describe( 'DOM', () => { | ||
let parent; | ||
|
||
beforeEach( () => { | ||
parent = document.createElement( 'div' ); | ||
document.body.appendChild( parent ); | ||
} ); | ||
|
||
afterEach( () => { | ||
parent.remove(); | ||
} ); | ||
|
||
describe( 'isEdge', () => { | ||
it( 'Should return true for empty input', () => { | ||
const input = document.createElement( 'input' ); | ||
parent.appendChild( input ); | ||
input.focus(); | ||
expect( isEdge( input, true ) ).toBe( true ); | ||
expect( isEdge( input, false ) ).toBe( true ); | ||
} ); | ||
|
||
it( 'Should return the right values if we focus the end of the input', () => { | ||
const input = document.createElement( 'input' ); | ||
parent.appendChild( input ); | ||
input.value = 'value'; | ||
input.focus(); | ||
input.selectionStart = 5; | ||
input.selectionEnd = 5; | ||
expect( isEdge( input, true ) ).toBe( false ); | ||
expect( isEdge( input, false ) ).toBe( true ); | ||
} ); | ||
|
||
it( 'Should return the right values if we focus the start of the input', () => { | ||
const input = document.createElement( 'input' ); | ||
parent.appendChild( input ); | ||
input.value = 'value'; | ||
input.focus(); | ||
input.selectionStart = 0; | ||
input.selectionEnd = 0; | ||
expect( isEdge( input, true ) ).toBe( true ); | ||
expect( isEdge( input, false ) ).toBe( false ); | ||
} ); | ||
|
||
it( 'Should return false if we\'re not at the edge', () => { | ||
const input = document.createElement( 'input' ); | ||
parent.appendChild( input ); | ||
input.value = 'value'; | ||
input.focus(); | ||
input.selectionStart = 3; | ||
input.selectionEnd = 3; | ||
expect( isEdge( input, true ) ).toBe( false ); | ||
expect( isEdge( input, false ) ).toBe( false ); | ||
} ); | ||
|
||
it( 'Should return false if the selection is not collapseds', () => { | ||
const input = document.createElement( 'input' ); | ||
parent.appendChild( input ); | ||
input.value = 'value'; | ||
input.focus(); | ||
input.selectionStart = 0; | ||
input.selectionEnd = 5; | ||
expect( isEdge( input, true ) ).toBe( false ); | ||
expect( isEdge( input, false ) ).toBe( false ); | ||
} ); | ||
|
||
it( 'Should always return true for non content editabless', () => { | ||
const div = document.createElement( 'div' ); | ||
parent.appendChild( div ); | ||
expect( isEdge( div, true ) ).toBe( true ); | ||
expect( isEdge( div, false ) ).toBe( true ); | ||
} ); | ||
} ); | ||
|
||
describe( 'placeCaretAtEdge', () => { | ||
it( 'should place caret at the start of the input', () => { | ||
const input = document.createElement( 'input' ); | ||
input.value = 'value'; | ||
placeCaretAtEdge( input, true ); | ||
jest.runAllTimers(); | ||
expect( isEdge( input, true ) ).toBe( true ); | ||
} ); | ||
|
||
it( 'should place caret at the end of the input', () => { | ||
const input = document.createElement( 'input' ); | ||
input.value = 'value'; | ||
placeCaretAtEdge( input, false ); | ||
jest.runAllTimers(); | ||
expect( isEdge( input, false ) ).toBe( true ); | ||
} ); | ||
} ); | ||
} ); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { Component } from 'element'; | ||
import { keycodes } from '@wordpress/utils'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { isEdge, placeCaretAtEdge } from '../utils/dom'; | ||
|
||
/** | ||
* Module Constants | ||
*/ | ||
const { UP, DOWN, LEFT, RIGHT } = keycodes; | ||
|
||
class WritingFlow extends Component { | ||
constructor() { | ||
super( ...arguments ); | ||
this.zones = []; | ||
this.onKeyDown = this.onKeyDown.bind( this ); | ||
this.onKeyUp = this.onKeyUp.bind( this ); | ||
this.bindContainer = this.bindContainer.bind( this ); | ||
this.state = { | ||
shouldMove: false, | ||
}; | ||
} | ||
|
||
bindContainer( ref ) { | ||
this.container = ref; | ||
} | ||
|
||
getVisibleTabbables() { | ||
const tabblablesSelector = [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a library or extracting to a general helper seems like a good thing to me. But I'm not sure it will work as expected, if I understand correctly There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be |
||
'*[contenteditable="true"]', | ||
'*[tabindex]:not([tabindex="-1"])', | ||
'textarea', | ||
'input', | ||
].join( ', ' ); | ||
const isVisible = ( elem ) => elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0; | ||
return Array.from( this.container.querySelectorAll( tabblablesSelector ) ).filter( isVisible ); | ||
} | ||
|
||
moveFocusInContainer( target, direction = 'UP' ) { | ||
const focusableNodes = this.getVisibleTabbables(); | ||
if ( direction === 'UP' ) { | ||
focusableNodes.reverse(); | ||
} | ||
|
||
const targetNode = focusableNodes | ||
.slice( focusableNodes.indexOf( target ) ) | ||
.reduce( ( result, node ) => { | ||
return result || ( node.contains( target ) ? null : node ); | ||
}, null ); | ||
|
||
if ( targetNode ) { | ||
placeCaretAtEdge( targetNode, direction === 'DOWN' ); | ||
} | ||
} | ||
|
||
onKeyDown( event ) { | ||
const { keyCode, target } = event; | ||
const moveUp = ( keyCode === UP || keyCode === LEFT ); | ||
const moveDown = ( keyCode === DOWN || keyCode === RIGHT ); | ||
|
||
if ( ( moveUp || moveDown ) && isEdge( target, moveUp ) ) { | ||
event.preventDefault(); | ||
this.setState( { shouldMove: true } ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to be using state for this, or could we assign an instance property? Thinking the latter would help to avoid two unnecessary rerenders. this.shouldMove = true; There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're probably right 👍 |
||
} | ||
} | ||
|
||
onKeyUp( event ) { | ||
const { keyCode, target } = event; | ||
const moveUp = ( keyCode === UP || keyCode === LEFT ); | ||
if ( this.state.shouldMove ) { | ||
event.preventDefault(); | ||
this.moveFocusInContainer( target, moveUp ? 'UP' : 'DOWN' ); | ||
this.setState( { shouldMove: false } ); | ||
} | ||
} | ||
|
||
render() { | ||
const { children } = this.props; | ||
|
||
return ( | ||
<div | ||
ref={ this.bindContainer } | ||
onKeyDown={ this.onKeyDown } | ||
onKeyUp={ this.onKeyUp } | ||
> | ||
{ children } | ||
</div> | ||
); | ||
} | ||
} | ||
|
||
export default WritingFlow; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't
return false;
have the same effect aspreventDefault
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess not since it fixed the issue (maybe it was the stopPropagation) but any way safer to just return