-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Try/2031 manual focus #2392
Conversation
blocks/api/registration.js
Outdated
@@ -68,6 +68,12 @@ export function registerBlockType( name, settings ) { | |||
); | |||
return; | |||
} | |||
if ( 'focusable' in settings && ! ( isFunction( settings.focusable ) || isBoolean( settings.focusable ) ) ) { |
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.
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.
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.
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.
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'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
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 now calculate if the block needs a focusable border.
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.
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.
editor/modes/visual-editor/block.js
Outdated
@@ -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; |
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.
Should this constant be added to utils/keycodes
?
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.
Yes. I could not find the file where that was defined. I will add it.
editor/modes/visual-editor/block.js
Outdated
} | ||
|
||
function isToolbar( target ) { | ||
return selectAncestor( target, '.components-toolbar, .editor-block-settings-menu, .editor-block-mover' ) !== null; |
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.
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
.
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 will read through those links and update my implementation appropriately - as you can probably tell I don't normally work with react.
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.
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.
editor/modes/visual-editor/block.js
Outdated
@@ -252,7 +284,18 @@ class VisualEditorBlock extends Component { | |||
onKeyDown( event ) { | |||
const { keyCode, target } = event; | |||
|
|||
this.handleArrowKey( event ); | |||
//this.handleArrowKey( event ); |
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.
Commented code should be removed.
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.
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.
editor/modes/visual-editor/block.js
Outdated
return; | ||
} | ||
const block = findDOMNode( this.node ); | ||
const isVisible = ( elem ) => elem && ! ( elem.offsetWidth <= 0 || elem.offsetHeight <= 0 ); |
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.
What nodes are we worried about here that are 0
width / height?
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.
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).
editor/modes/visual-editor/block.js
Outdated
} | ||
|
||
handleToolbarTabCycle( event ) { | ||
const { keyCode, shiftKey, target } = event; |
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.
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.
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.
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.
editor/modes/visual-editor/block.js
Outdated
if ( allCycle.length > 1 ) { | ||
const nextToolbar = ( targetIndex + 1 >= allCycle.length ) ? allCycle[ 0 ] : allCycle[ targetIndex + 1 ]; | ||
|
||
const firstFocusableItem = nextToolbar.querySelector( 'button, *[tabindex]' ); |
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.
This isn't fully representative of finding first focusable, i.e. would match a disabled button, or tabIndex=-1
.
Related effort ongoing in #2323
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.
Good point, I will improve on this.
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.
Note, tabIndex=-1 is exactly what I want to select in this case. I have made the selector avoid disabled buttons.
Codecov Report
@@ 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
Continue to review full report at Codecov.
|
editor/modes/visual-editor/block.js
Outdated
return; | ||
} | ||
const forward = keyCode === DOWN || keyCode === RIGHT; | ||
const block = findDOMNode( this.node ); |
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.
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).
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.
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 ) ) { |
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.
A few alternative ideas to reaching up through parent ancestry from the event target:
- 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 ) )
-
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 likeonEscape
. -
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.
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 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.
editor/modes/visual-editor/block.js
Outdated
|
||
function FirstChild( { children } ) { | ||
const childrenArray = Children.toArray( children ); | ||
return childrenArray[ 0 ] || null; | ||
} | ||
|
||
function queryFirstTabbableChild( elem ) { | ||
let i = 0; |
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.
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
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.
Good point, I will fix it.
editor/modes/visual-editor/block.js
Outdated
function queryFirstTabbableChild( elem ) { | ||
let i = 0; | ||
for ( i = 0; i < elem.childNodes.length; i++ ) { | ||
const tabbableNode = queryFirstTabbable( { context: elem.childNodes[ i ] } ); |
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.
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 } );
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.
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).
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.
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> |
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.
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.
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.
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).
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.
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.
editor/modes/visual-editor/block.js
Outdated
this.handleToolbarTabCycle( event ); | ||
this.handleToolbarArrowCycle( event ); | ||
|
||
if ( keyCode === F10 && ! ( event.altKey || event.ctrlKey || event.shiftKey || event.metaKey ) ) { |
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 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?
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.
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 } ); |
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.
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?)
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.
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.
editor/modes/visual-editor/block.js
Outdated
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)' ) ) ); |
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.
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
)
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.
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.
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.
Oh, nevermind. I see that I forgot the predicate!
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 have added the missing predicate to the filter function and removed the Array.from calls.
I removed or moved code so therefore coverage should go up? |
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, |
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.
With these changes, what are the implications on block authors for managing tab index? How do we document this? Can we avoid it?
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.
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"
?
My overarching concerns at this point are:
|
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.
I consider that the code controlling the block is the most logical place to control focus for the block.
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?!)... |
@aduth : are you happy to close this one off? |
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. |
A lot has changed and most of the behaviors introduced by this PR already exist now. |
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