Skip to content
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

Merged
merged 12 commits into from
Aug 29, 2017

Conversation

youknowriad
Copy link
Contributor

related #2421 and transversal to #2296

This doesn't change the way arrow navigation works (like #2296 does) but just extracts it to its own component.

Pros

  • The VisualEditorBlock component handles way more than it should
  • More composable approach, the WritingFlow component can wrap anything to allow arrow navigation between tabbables.
  • This fixes a bug where we were stuck in the PostTitle while still allowing the PostTitle to be used in the text mode without duplicating the navigation behaviour

Notes

  • Like suggested in How can we improve the writing flow and its stability? #2421 I think we should think more about how to "compose" all these behaviours. I think this component could also handle Enter and Backspace navigation the same way we do for arrow navigation. It could be done separately and probably after merging Improved arrow navigation #2296

  • Not done in this PR yet, I think the isTyping reducer/actions should be handled by this component (WriningFlow) because the PostTitle should be able to update this property too.

@youknowriad youknowriad self-assigned this Aug 15, 2017
@codecov
Copy link

codecov bot commented Aug 15, 2017

Codecov Report

Merging #2424 into master will increase coverage by 2.68%.
The diff coverage is 19.54%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #2424      +/-   ##
==========================================
+ Coverage   31.57%   34.26%   +2.68%     
==========================================
  Files         175      177       +2     
  Lines        5304     6173     +869     
  Branches      914     1176     +262     
==========================================
+ Hits         1675     2115     +440     
- Misses       3081     3390     +309     
- Partials      548      668     +120
Impacted Files Coverage Δ
editor/writing-flow/index.js 0% <0%> (ø)
blocks/url-input/index.js 1.63% <0%> (-0.06%) ⬇️
editor/modes/visual-editor/index.js 0% <0%> (ø) ⬆️
editor/modes/visual-editor/block.js 0% <0%> (ø) ⬆️
editor/utils/dom.js 40.47% <40.47%> (ø)
editor/sidebar/post-author/index.js 39.53% <0%> (-45.47%) ⬇️
editor/header/tools/publish-button.js 53.48% <0%> (-34.98%) ⬇️
editor/sidebar/post-sticky/index.js 26.08% <0%> (-28.46%) ⬇️
blocks/library/image/index.js 54.16% <0%> (-8.34%) ⬇️
editor/sidebar/page-attributes/index.js 69.44% <0%> (-5.56%) ⬇️
... and 29 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 09380a4...14b7a7f. Read the comment docs.

@aduth
Copy link
Member

aduth commented Aug 16, 2017

I think this is a good direction and maybe we ought to take it a step further to have WritingFlow act as a coordinator using callbacks from Editable instances directly. It should probably be the responsibility of the Editable component to emit the specific behaviors (i.e. onFocusPrevious, onDeleteToPrevious) rather than inferring these from keyboard events. I'm seeing WritingFlow being the one to render EditableProvider and using context-based function callbacks to respond to these events, transitioning focus between Editables. I played with some ideas earlier today where WritingFlow could track all Editables within it, but it proved to be very difficult to do in a React-y way. Maybe we'll still at least need DOM APIs to find the previous and next focus targets.

I think the general advantage here is in isolating text interactivity, since it's quite complex, and certainly not related to rendering the block's editable form.

I'll plan to do a more thorough review shortly.

@youknowriad
Copy link
Contributor Author

It should probably be the responsibility of the Editable component to emit the specific behaviors (i.e. onFocusPrevious, onDeleteToPrevious) rather than inferring these from keyboard events.

@iseulde Had a similar idea where we provide a HoC to wrap any "input" (editables, textareas, input) to emit these calls.
What concerns me here is that this forces people to use Editable or a special input component if they just want text without formatting or anything on their block.
I see the Editable component as one way among others to make the block content "editable" and not the only way.
The DOM approach has the advantage of the genericity.

@aduth
Copy link
Member

aduth commented Aug 16, 2017

Another benefit to isolating these behaviors is that it makes them more testable... ahem... AHEM 😉

@youknowriad
Copy link
Contributor Author

@aduth Understood :). Was kind of waiting the direction taken by #2296 first.

@youknowriad
Copy link
Contributor Author

@iseulde @aduth @karmatosed @jasmussen @mtias

I worked on this today and tried to resolve the navigation issues we were having #2104 and #2124
I've used a full DOM approach (no redux state), this makes it "generic".
It seems to work fine for me. It still lacks unit tests, but wanted to have your input.

@ellatrix
Copy link
Member

@youknowriad At first sight, that looks good and makes sense. I'll have a deeper look tonight or tomorrow.

@ellatrix
Copy link
Member

Merged isEdge from

function isEdge( { editor, reverse } ) {
😊
Feel free to reset of course.

@ellatrix
Copy link
Member

Another benefit to isolating these behaviors is that it makes them more testable... ahem... AHEM 😉

Some parts may be testable, but not sure how we should test the selection stuff? Manual browser tests? Something à la PhantomJS?

@youknowriad Tested a bit, but when the Editable has been focussed already, the caret moves to the last position.

@youknowriad
Copy link
Contributor Author

@iseulde Thanks for the change, this is better 👍

@youknowriad
Copy link
Contributor Author

@iseulde Improved the caret moves, it should behave better now.

The last problem I see is with the Table block, but I'm not sure how to fix it. I wonder if we could move forward anyway.

* @param {Element} container DOM Element
* @param {Boolean} start Position: Start or end of the element
*/
export function placeCaretAtEdge( container, start = false ) {
Copy link
Member

Choose a reason for hiding this comment

The 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.)

@ellatrix
Copy link
Member

@youknowriad I think I'm still having the same problem... To reproduce:

  • Put the cursor with the pointer in the middle of an editable.
  • Put the cursor in the next one, again with the pointer.
  • Now use the arrow keys to move the caret up.
  • I ends up being in the start position, not at the very end of the block.

@youknowriad
Copy link
Contributor Author

youknowriad commented Aug 29, 2017

@iseulde Thanks for the steps, this is really hard to get right.

I noticed that when moving up, the keyUp event was causing the caret to be moved to the beginning (or middle of a block) even if the keyDown put the cursor at the right position. So I moved the placeCaretAtEdge to the keyUp event.

@mtias
Copy link
Member

mtias commented Aug 29, 2017

This is looking good to me.

@@ -104,6 +104,10 @@ class UrlInput extends Component {

onKeyDown( event ) {
const { selectedSuggestion, posts } = this.state;
if ( ! this.state.showSuggestions || ! this.state.posts.length ) {
Copy link
Member

Choose a reason for hiding this comment

The 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.

@youknowriad
Copy link
Contributor Author

unfortunately, jsdom doesn't support the selection API, I was only able to test the "input/textarea" use-case.

As suggested by @iseulde This could be tested in potential integration tests.

container.focus();
setTimeout( () => {
if ( start ) {
container.selectionStart = 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know that container still exists at this point? To the more pertinent point, do we need the setTimeout ? Can we comment as such to why it is necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, the timeout is not necessary anymore :)

return;
}

function placeCaretInContentEditable( element, atStart ) {
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep probably not necessary

}

getVisibleTabbables() {
const tabblablesSelector = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in #2323 and #2392, we're increasingly drawn to needing general helpers with this sort of logic, and am tempted to bring in ally.js despite it not being terribly compact. Don't need to make changes here, but curious if you have thoughts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 ally.js returns different tabbables depending on the browser. We'll have to test I guess

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be tabbablesSelector?

Copy link
Member

@aduth aduth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works well in Chrome, Safari, Firefox on macOS. The behavior in Edge is not so great. Pressing "Down" when in the middle of the last line of a paragraph does nothing. Sometimes when pressing from end of a paragraph to the next, it moves to the middle of the current paragraph instead (anecdotally, after an italicized text?)

The dance of onKeyDown and onKeyUp looks a bit awkward, and more importantly feels not quite right in usage (especially when not quickly pressing arrow buttons) but I sense there was some difficulty in getting this quite right, so maybe some iterative improvements can be made.

@@ -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
Copy link
Member

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 as preventDefault?

Copy link
Contributor Author

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

'input',
].join( ', ' );
const isVisible = ( elem ) => elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0;
return Array.from( this.container.querySelectorAll( tabbablesSelector ) ).filter( isVisible );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (stylistic): Can be expressed with array spread too:

[ ...this.container.querySelectorAll( tabbablesSelector ) ]

@youknowriad
Copy link
Contributor Author

The dance of onKeyDown and onKeyUp looks a bit awkward, and more importantly feels not quite right in usage (especially when not quickly pressing arrow buttons) but I sense there was some difficulty in getting this quite right, so maybe some iterative improvements can be made.

Yes, it was initially all handled in onKeyDown but #2424 (comment)

@youknowriad
Copy link
Contributor Author

The behavior in Edge is not so great. Pressing "Down" when in the middle of the last line of a paragraph does nothing. Sometimes when pressing from end of a paragraph to the next, it moves to the middle of the current paragraph instead (anecdotally, after an italicized text?)

I suspect the selection API behaves slightly differently in Edge. Unfortunately, I can't fix this right now (no VM and no bandwidth to get it).

@youknowriad
Copy link
Contributor Author

Ok merging. We'll address the remaining issues in other PRs


if ( ( moveUp || moveDown ) && isEdge( target, moveUp ) ) {
event.preventDefault();
this.setState( { shouldMove: true } );
Copy link
Member

@aduth aduth Oct 2, 2017

Choose a reason for hiding this comment

The 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;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably right 👍

Comment on lines +109 to +111
if ( ! this.state.showSuggestions || ! this.state.posts.length ) {
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm led here via #19462 (comment) , and trying to understand what it was that we were trying to accomplish with this condition. I'm guessing at the time it was when we were first introducing WritingFlow and using arrow keys to navigate between blocks. But in considering a link editor input: Why should we care whether there are suggestions present in deciding whether to prevent the default arrow key behavior? Based on what I describe in #19462 (comment), I sorta expect that arrow keys should always use the native behavior in the input, regardless of suggestions present. I don't really expect to be able to navigate between blocks in this context.

Trying to recall: Did we used to have the link fields inline within the blocks list, and this was how we tried to allow the arrow keys to let the user go to the next/previous focusable field? Most all of those are in popovers now, correct? Maybe we don't want to assume that, but I also don't think we need to optimize for WritingFlow here either.

In some ways it relates to #19488 (#18755), where I talk about what it is about these inputs which make us want them to be ignored by WritingFlow, ObserveTyping, etc. More and more, I think it's that being in a popover (modal) gives it its own context, where it should be treated as detached from the rest of the editor (and thus not inherit any of those typing behaviors).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I sorta expect that arrow keys should always use the native behavior in the input, regardless of suggestions present.

If URLInput is not used in the popover (Button block at that time), it shouldn't be "an arrow navigation trap"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If URLInput is not used in the popover (Button block at that time), it shouldn't be "an arrow navigation trap"

Yeah, thinking about it more, it feels like "popover" is more the exception (like previously mentioned with related #19488, #18755). It has me wondering whether it would be reasonable to treat popovers as entirely separate context, and to (by default) stop all event bubbling propagation from escaping. Maybe that's an extreme approach, but I kinda see it making sense as a totally independent document, almost like an iframe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants