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

Try/2031 manual focus #2392

Closed
wants to merge 36 commits into from
Closed

Try/2031 manual focus #2392

wants to merge 36 commits into from

Conversation

BoardJames
Copy link

@BoardJames BoardJames commented Aug 14, 2017

The purpose of this pull request is to try out an alternate idea for keyboard navigation based on techniques used in textbox.io and tinymce.

In the pull request the toolbar is not in the normal focus cycle so pressing tab / shift+tab will move between blocks.

The toolbar can be selected by pressing F10 while editing a block which will display and focus the toolbar.
Toolbar groups can be moved between by using tab / shift+tab and the focus will not leave the toolbars while in this mode.
Toolbar items can be moved between by pressing Right_arrow / Left_arrow and as with tab the selection will not leave the toolbar while in this mode.
To exit toolbar mode and reselect the editor press Esc.

Pressing Esc while the editor is selected will deselect the block as before (though I'm not sure that is a good idea from an accessibility perspective)

Note I also changed the block switcher so it uses the drop-down menu so we didn't have two implementations of drop-down menus.

Note that this is meant to address #2031

@@ -68,6 +68,12 @@ export function registerBlockType( name, settings ) {
);
return;
}
if ( 'focusable' in settings && ! ( isFunction( settings.focusable ) || isBoolean( settings.focusable ) ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

Can you explain the purpose of this focusable property? It's not clear to me.

We'll also want to include relevant documentation updates in docs/

If at all possible, it would be ideal to not impose this implementation detail onto block authors.

Copy link
Author

Choose a reason for hiding this comment

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

Sure!
Basically the problem is that the box around a block adds an additional element to the focus cycle that I wanted to remove but at the same time we need some way to focus blocks with no focusable components like the horizontal divider. Also with some blocks like the picture blocks they can be focused before you put the picture in as there is a button but after they have a picture there is nothing within the block to focus so the property needed to support a function.

Currently the property defaults to true so block owners will only have to care about it if their block has nothing focusable. I could also attempt to calculate post-render if there is anything focusable in a block and set the tabIndex of the surrounding div then.

Copy link
Author

Choose a reason for hiding this comment

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

I've thought about this and I think I will change the implementation to do the following:

  • Initially assume that no child components are focusable and set the state variable focusable to false
  • Render the block and set the tabIndex of the border based on the focusable state
  • After the block has rendered then calculate if any of the components within the block can be focused according to the rules here: https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
  • If the block border is focused and a child element is focusable than transfer focus to the first focusable child
  • Update the state variable focusable

Copy link
Author

Choose a reason for hiding this comment

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

I now calculate if the block needs a focusable border.

Copy link
Author

@BoardJames BoardJames Aug 15, 2017

Choose a reason for hiding this comment

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

Note that I still need to figure out how to nicely handle selection when tabbing into a block that displays things when the focus property is set.
For example the "button" block has 3 focusable elements:

  • The button text
  • A link (only shown when the block is focused)
  • A button associated with the link (only shown when the block is focused)

This gives inconsistent results when tabbing backwards because the link and link-button are skipped over to select the button-text but they aren't when tabbing forwards because they are displayed once the button text has been selected.

@@ -51,13 +51,28 @@ import {
getMultiSelectedBlockUids,
} from '../../selectors';

const { BACKSPACE, ESCAPE, DELETE, UP, DOWN, LEFT, RIGHT, ENTER } = keycodes;
const { BACKSPACE, ESCAPE, DELETE, UP, DOWN, LEFT, RIGHT, ENTER, TAB } = keycodes;
const F10 = 121;
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 constant be added to utils/keycodes?

Copy link
Author

Choose a reason for hiding this comment

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

Yes. I could not find the file where that was defined. I will add it.

}

function isToolbar( target ) {
return selectAncestor( target, '.components-toolbar, .editor-block-settings-menu, .editor-block-mover' ) !== null;
Copy link
Member

Choose a reason for hiding this comment

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

A component really ought not traverse the DOM, especially through nodes not immediately rendered by its own render, since it establishes dependencies between the components which are difficult to maintain and debug. It would not be very obvious to add a new entry to this list if we add another toolbar, for example.

Why do we not capture this event at the specific toolbar component(s) instead? If we then need to communicate this to another component, we can employ one of a number of strategies.

Aside: We have element-closest available as a polyfill for Element#closest.

Copy link
Author

Choose a reason for hiding this comment

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

I will read through those links and update my implementation appropriately - as you can probably tell I don't normally work with react.

Copy link
Member

Choose a reason for hiding this comment

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

It's a very tricky problem because the very purpose of what you're implementing is to manage the relationship between descendant components (i.e. moving from blocks into their toolbar). @youknowriad has been exploring some ideas in #2424 (#2421) that at least have an advantage of isolating these behaviors, if not eliminating the DOM querying.

This is also an area where it would be nice to have fully controlled focus state (related to ideas by @iseulde at #771). So instead of VisualEditorBlock finding and calling focus on the element directly, the toolbar dispatches the change in focus to the block and the relevant editable detects and calls focus on itself. This is a larger unsolved problem, and not one we really need to account for here.

@@ -252,7 +284,18 @@ class VisualEditorBlock extends Component {
onKeyDown( event ) {
const { keyCode, target } = event;

this.handleArrowKey( event );
//this.handleArrowKey( event );
Copy link
Member

Choose a reason for hiding this comment

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

Commented code should be removed.

Copy link
Author

Choose a reason for hiding this comment

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

Yep, I should probably remove the function it references too. I am still considering if it contains something I needed to implement. I think I may have removed arrow key navigation between blocks which is something that probably needs to be added back.

return;
}
const block = findDOMNode( this.node );
const isVisible = ( elem ) => elem && ! ( elem.offsetWidth <= 0 || elem.offsetHeight <= 0 );
Copy link
Member

Choose a reason for hiding this comment

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

What nodes are we worried about here that are 0 width / height?

Copy link
Author

Choose a reason for hiding this comment

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

It's a quick check to see if the element or any ancestors are hidden. It seems that sometimes React hides things rather than re-rendering and actually removing them (at least on Firefox).

}

handleToolbarTabCycle( event ) {
const { keyCode, shiftKey, target } = event;
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we can create a separate component which manages groups of toolbars and the keyboard navigation between them? This code doesn't seem particularly relevant to block rendering.

That component could then listen to changes in block focus state.

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, the implementation is currently far from ideal, lots of duplicated code is the main thing irritating me at the moment. I mainly wanted to get some other eyes on it to see if people liked or hated the new keyboard nav.

if ( allCycle.length > 1 ) {
const nextToolbar = ( targetIndex + 1 >= allCycle.length ) ? allCycle[ 0 ] : allCycle[ targetIndex + 1 ];

const firstFocusableItem = nextToolbar.querySelector( 'button, *[tabindex]' );
Copy link
Member

Choose a reason for hiding this comment

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

This isn't fully representative of finding first focusable, i.e. would match a disabled button, or tabIndex=-1.

Related effort ongoing in #2323

Copy link
Author

Choose a reason for hiding this comment

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

Good point, I will improve on this.

Copy link
Author

Choose a reason for hiding this comment

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

Note, tabIndex=-1 is exactly what I want to select in this case. I have made the selector avoid disabled buttons.

@codecov
Copy link

codecov bot commented Aug 18, 2017

Codecov Report

Merging #2392 into master will decrease coverage by 0.42%.
The diff coverage is 7.61%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #2392      +/-   ##
==========================================
- Coverage   31.59%   31.17%   -0.43%     
==========================================
  Files         175      175              
  Lines        5307     5386      +79     
  Branches      916      943      +27     
==========================================
+ Hits         1677     1679       +2     
- Misses       3080     3131      +51     
- Partials      550      576      +26
Impacted Files Coverage Δ
blocks/library/gallery/index.js 33.33% <ø> (ø) ⬆️
editor/block-settings-menu/index.js 0% <ø> (ø) ⬆️
components/toolbar/index.js 100% <ø> (ø) ⬆️
blocks/library/table/table-block.js 8% <ø> (ø) ⬆️
blocks/library/image/block.js 0% <ø> (ø) ⬆️
editor/block-mover/index.js 0% <ø> (ø) ⬆️
editor/modes/visual-editor/block.js 0% <0%> (ø) ⬆️
blocks/url-input/button.js 0% <0%> (ø) ⬆️
editor/block-switcher/index.js 0% <0%> (ø) ⬆️
blocks/library/code/index.js 50% <0%> (-7.15%) ⬇️
... and 3 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 a9de9d2...0e533c2. Read the comment docs.

return;
}
const forward = keyCode === DOWN || keyCode === RIGHT;
const block = findDOMNode( this.node );
Copy link
Member

Choose a reason for hiding this comment

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

this.node should already be an HTMLElement, since it's assigned from the root <div>. You should only need findDOMNode if you're attaching a ref to another component, or when passing this as an argument (which would have the same result).

Copy link
Author

Choose a reason for hiding this comment

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

Ah, I just assumed that all react refs were some wrapper object.

@@ -210,7 +251,13 @@ class VisualEditorBlock extends Component {

// Deselect on escape.
if ( ESCAPE === keyCode ) {
onDeselect();
if ( isToolbar( target ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

A few alternative ideas to reaching up through parent ancestry from the event target:

  1. If what we're looking for already exists in the rendered element, we could check whether the target is contained within one of them. Something like:
const isToolbar = [
	this.nodes.toolbar,
	this.nodes.blockSettings,
	this.nodes.blockMover
].some( ( node ) => node.contains( event.target ) )
  1. Maybe it should be the toolbar, block settings, and block mover which handle the keypress and surface this up to VisualEditorBlock through a prop callback like onEscape.

  2. onFocus could be called anywhere, including the toolbar, block settings, and block mover components, on their own "on escape press" handling

My preference is toward the second: For behaviors which are specific to component(s), it's nice to implement those behaviors within the component itself, and surface them to the parent component semantically through named prop callbacks.

Copy link
Author

Choose a reason for hiding this comment

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

I rejected option 1 as being inefficient.

Another option is for the toolbars to fire custom events instead of a prop callback. The problem with prop callbacks are that the toolbars are not the direct child of the block.


function FirstChild( { children } ) {
const childrenArray = Children.toArray( children );
return childrenArray[ 0 ] || null;
}

function queryFirstTabbableChild( elem ) {
let i = 0;
Copy link
Member

Choose a reason for hiding this comment

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

Unnecessary assignment since the for on the next line will set it to 0 anyways. Could also move entire declaration into the loop:

for ( let i = 0; i < elem.childNodes.length; i++ ) {

Aside: let and const have different scoping implications than var, so conventions around assigning all variables top of function need not necessarily apply.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let#Scoping_rules_2

Copy link
Author

Choose a reason for hiding this comment

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

Good point, I will fix it.

function queryFirstTabbableChild( elem ) {
let i = 0;
for ( i = 0; i < elem.childNodes.length; i++ ) {
const tabbableNode = queryFirstTabbable( { context: elem.childNodes[ i ] } );
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 need this loop at all? Or function for that matter? What's the difference between this and:

const queryFirstTabbableChild = ( elem ) => queryFirstTabbable( { context: elem } );

Copy link
Author

Choose a reason for hiding this comment

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

That includes the element itself in the query so I can't use it like that. I tried passing an array but then I found that it silently discards all but the first element of the array (same with a selector despite using querySelectorAll internally which was quite confusing).

Copy link
Member

Choose a reason for hiding this comment

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

That includes the element itself in the query so I can't use it like that.

Digging through the docs and implementation of ally's queryFirstTabbable, it doesn't seem like this should be the case. Similarly in my testing I find by default the context is not considered:

https://jsbin.com/tubexul/3/edit?html,css,js,output

Seems like this only applies when using either defaultToContext or the underlying queryTabbable's includeContext (default false).

}
</div>
<Toolbar>
<li>
Copy link
Member

Choose a reason for hiding this comment

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

Aside: Ideally a consumer of the Toolbar component should not care that it renders itself as a ul, and could pass children without the li. In the implementation of the Toolbar component, children can be achieved with React.Children.map:

<ul>
	{ React.Children.map( ( child, i ) => (
		<li key={ i }>{ child }</li>
	) ) }
</ul>

Mentioning as aside since it's not really relevant to the scope of your pull request.

Copy link
Author

Choose a reason for hiding this comment

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

It would make using the Toolbar with children slightly easier (and I have considered it), however it would also remove some control from the caller (ie to add classes to some li elements).

Copy link
Member

Choose a reason for hiding this comment

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

however it would also remove some control from the caller (ie to add classes to some li elements).

To me, this is not control we want to allow, or at least not through exposing li directly. There are a few alternatives, such as a prop on Toolbar, a wrapper for each children like ToolbarItem, a wrapper of the item itself (apply class to a div parent of DropdownMenu), or more advanced examples like function as child components.

Ultimately, I don't think we know enough about the actual use cases to need to concern ourselves with this control just yet though.

this.handleToolbarTabCycle( event );
this.handleToolbarArrowCycle( event );

if ( keyCode === F10 && ! ( event.altKey || event.ctrlKey || event.shiftKey || event.metaKey ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

I like how you delegated specific logic to helper functions for handleToolbarTabCycle and handleToolbarArrowCycle. Any reason we shouldn't do the same for the behavior here?

Copy link
Author

Choose a reason for hiding this comment

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

No reason. I will put this in a function to make it more readable.

if ( this.props.isTyping ) {
this.props.onStopTyping();
}
this.props.onFocus( this.props.uid, { toolbar: true } );
Copy link
Member

Choose a reason for hiding this comment

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

Curious: Do we need toolbar focus to be a concern of the Redux focus state? I don't feel particularly strongly either way, but it seems like we could achieve the same effect with the component's local state. Unless we're ever moving focus from one block to another and assigning { toolbar: true } in the process (would this be handled well if it were to occur?)

Copy link
Author

Choose a reason for hiding this comment

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

The problem is that the toolbar is hidden most of the time so it must be shown before it can be focused. Since I had to use react to show it it seemed simplest to focus it using the react lifecycle.

const settingsMenu = filter( [ block.querySelector( '.editor-block-settings-menu' ) ], isVisible );
const moverMenu = filter( [ block.querySelector( '.editor-block-mover' ) ], isVisible );
const allCycle = flatMap( [ ...allToolbars, ...settingsMenu, ...moverMenu ], ( toolbar ) => {
return filter( Array.from( toolbar.querySelectorAll( '*[tabindex="-1"]:not(:disabled)' ) ) );
Copy link
Member

Choose a reason for hiding this comment

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

What is filter doing here?

If we did need it, I believe it's also capable of handling NodeList (in which case wouldn't need Array.from)

Copy link
Author

Choose a reason for hiding this comment

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

The Array.from came from the other keyhanding code that I deleted (and I see has since been moved into another file). The filter is there because sometimes toolbars are hidden by css media query rules and I figured I'd play it safe and check the focusable items in the toolbar for visibility too.

Copy link
Author

Choose a reason for hiding this comment

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

Oh, nevermind. I see that I forgot the predicate!

Copy link
Author

Choose a reason for hiding this comment

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

I have added the missing predicate to the filter function and removed the Array.from calls.

@BoardJames
Copy link
Author

BoardJames commented Aug 24, 2017

I removed or moved code so therefore coverage should go up?

@anna-harrison
Copy link

Hey @aduth : could you have a quick look at this one and clarify what is needed to merge this?

@@ -101,6 +101,7 @@ registerBlockType( 'core/gallery', {
buttonProps={ {
className: 'components-icon-button components-toolbar__control',
'aria-label': __( 'Edit Gallery' ),
tabIndex: -1,
Copy link
Member

@aduth aduth Aug 28, 2017

Choose a reason for hiding this comment

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

With these changes, what are the implications on block authors for managing tab index? How do we document this? Can we avoid it?

Copy link
Member

Choose a reason for hiding this comment

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

Is it possible to hijack the tab keypress to avoid the default tabIndex behavior when in the tabbable cycle, so that we don't need buttons to be assigned with tabIndex="-1"?

@aduth
Copy link
Member

aduth commented Aug 28, 2017

My overarching concerns at this point are:

  • A block implementer should not need to concern themselves with the implementation details of the focus cycle, as we've started to require new tabIndex props in blocks here
  • VisualEditorBlock is a nightmare of a component which is already responsible for handling far too many behaviors. The intricacies of toolbar focus should ideally be isolated from block rendering.
  • DOM traversal via Element#closest escapes concerns of the component itself both in terms of reaching up through the DOM hierarchy and in having knowledge of the implementation details of its child components (VisualEditorBlock should not need to know that .components-toolbar is the class name assigned to <Toolbar />). This can become difficult to maintain in cases where any of these classes change, or new toolbar types are added, or one of these classes becomes assigned to an ancestor node.

@BoardJames
Copy link
Author

@aduth

A block implementer should not need to concern themselves with the implementation details of the focus cycle

Unfortunately I think that is an impossible goal. There is always going to have to be some marker of things that can be focused and tabIndex is the one that is supported by browsers. Currently I have only modified tabIndex where it was needed to remove things from the tab cycle like buttons (which browsers automatically give an effective tabIndex of 0). I see no way of doing that automatically in all cases (barring a general AI). We might be able to do it automatically for buttons in tool bars but as you allude to in your other comments assuming things about child components is a dangerous thing to do.

VisualEditorBlock is a nightmare of a component which is already responsible for handling far too many behaviors. The intricacies of toolbar focus should ideally be isolated from block rendering.

I consider that the code controlling the block is the most logical place to control focus for the block.

DOM traversal via Element#closest escapes concerns of the component itself both in terms of reaching up through the DOM hierarchy and in having knowledge of the implementation details of its child components (VisualEditorBlock should not need to know that .components-toolbar is the class name assigned to ). This can become difficult to maintain in cases where any of these classes change, or new toolbar types are added, or one of these classes becomes assigned to an ancestor node.

Well I could filter the result returned by Element#closest using Node#contains to ensure that it is within the block but you don't seriously expect that a block would ever be wrapped in a class called 'components-toolbar'?!! Also the only way you are going to avoid using a selector on '.components-toolbar' is by passing out refs to the components which means you'd have to pass in a prop through the block to the toolbars (and you were complaining about giving extra responsibility to block owners before - how are you going to ensure that they do that?!)...

@anna-harrison
Copy link

@aduth : are you happy to close this one off?

@aduth
Copy link
Member

aduth commented Oct 10, 2017

The behavior may still be needed per #2031 , but my previously noted concerns still stand. Separately there has been some explorations of alternative toolbar placement like that in #2148 . I noticed that @youknowriad opened #2960 which looks to tackle some of the same tasks as those implemented here.

@youknowriad
Copy link
Contributor

A lot has changed and most of the behaviors introduced by this PR already exist now.
Thanks for your work on this PR @EphoxJames I'm closing right now. Let's open separate PRs to tackle the remaining tasks.

@gziolo gziolo deleted the try/2031-manual-focus branch May 7, 2018 11:15
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.

6 participants