-
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
General navigable component #3303
Conversation
This is then used in #3281 |
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.
Awesome job here!!
I left some comments, I'm trying to minimize the external API as much as possible. (which is generally good for maintenance) and I'm wondering if the "mode" abstraction is necessary.
But overall, this is great.
|
||
### initialSelector | ||
|
||
A selector which tells the component what to focus first inside it. If the selector is not matched, the first focusable item will be focused. This can be useful to focus the previously selected descendant when this component get focused. |
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 tend to avoid "selector" props because they break quickly without noticing.
Is there a way we could remove this prop entirely, say by always focusing the first tabbable or something?
--
Alternatively, we could try to always fallback to the tabbable with aria-selected
if present
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 liked being able to use classes for things which have 'fake focus' in the past, but it's easy enough to add later I suppose if we need it. The aria-selected
selector is definitely currently needed in TabPanel (you don't want to just focus the first tab if one was previously selected), so I guess we could look for just that first and fallback to the first one as you say. I tend to over-engineer rather than under-engineer, so you're just seeing that excess :)
|
||
### widget | ||
|
||
A boolean which specifies whether or not the focusable elements in this component might contain the focus, rather than have the focus. This is often used for `TabbableContainer` layouts that might have arrow key navigation inside each tabstop. |
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.
Not sure I understand, could you clarify a bit?
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'll clarify here, and then it makes sense here, I'll (try to remember to) update the readme.
The way this component is setup is designed to simplify composing different navigation models. Because it's the example in the other PR, it's easiest to to use the InserterMenu as an example.
The InserterMenu has a tabstop at the row of tabs, and a tabstop at the grid for each category, and a tabstop for the searcher. Now, the outer container of the inserter menu is just a TabbableContainer
, with only knowledge of the three tabstops. However, once the row of tabs gets focused (for example), its own navigation activates. The row of tabs is a NavigableMenu, so it focused the selected tab inside.
Now, if the user presses tab, the NavigableMenu doesn't handle that, so it safely bubbles to the TabbableContainer. Now, the tabbablecontainer has to work out which of its tabstops currently has focus. But none of its tabstops currently have focus. A button which is inside one of its current tabstops has focus, but the tabstop itself doesn't. That's because the tabstop for the row of tabs is a widget.
It's part of the purpose of these modes. I've written something very similar to this before in a UI library I've made previously, so I'm probably guilty of trying to replicate something I feel works fairly well. Navigation often needs to be composable. Something on the outside tabs in between various tabtops, and those tabstops themselves have arrow key navigation (is the common example). Some things are even more complex. By allowing for different modes, you make it possible to compose different types of navigation within each other, and allow bubbling of unhandled keys (by a particular navigation mode) pass the keys to the outer navigation layer which does care about those keys. Once you set it up this way, all types of navigation become fairly simple to implement. If you discover that you need to handle ENTER or ESCAPE or something similar, or different types of keys for switching focus (HOME and END), then you just have to either add modes, or add configuration to a mode.
I realised I just tried to explain what modes are for. Does any of that make sense?
- Required: No | ||
- Default: `"vertical"` | ||
|
||
#### stopArrowKeys |
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 we find a better name? preventBubblingArrowEvents
or something. I'm bad at naming things though :)
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 name sounds slightly better :)
|
||
### width | ||
|
||
An integer specifying the number of components that are displayed across the screen in one row. This relates to how the arrow keys will determine what to focus next. |
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.
My personal preference would be to drop this in favor of having the right
behave exactly as down
...
What if we have incomplete rows, this width
prop won't be sufficient to ensure the right behavior
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 particular mode is designed to only handle a situation where the grid is laid out visually in rows (due to wrapping) but there is no DOM structure that enforces those rows (so it's essentially just CSS). I found that when playing with the InsertMenu in the blocks section, pressing down
and seeing the focus shift to the right was very disconcerting, so this is just to allow a nice clean navigation for grids with CSS wrapping. The problem is that you either need to calculate the width at run-time (via y position changes), or know it in advance (which you might know depending on how your CSS is making it show in different rows).
This code is designed to work for that situation for a regular grid. The regular grid can have fewer columns on its last row, but the last row is the only one that can be different. It is basically an exact match for what I was seeing with the block categories on the Inserter Menu. We've used it in the past for emoticon panels etc. Users tend to expect to be able to navigate across a grid with the arrow keys.
Having said all that, if you do want to avoid doing this, and only use grid navigation for situations when each row is a new DOM structure (like a table), then that makes sense. It means there is a lot less guesswork, obviously.
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.
We had this kind of navigation in the firsts iterations of the Inserter, but for some reason, it was reverted to a simpler alternative. Shame that I don't recall the reason and I couldn't find the PR responsible for that neither :(
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 reverted it to just use down to the move to the right.
return { | ||
...pick( tabConfig, [ 'deep', 'widget', 'initialSelector' ] ), | ||
useTabstops: true, | ||
detect: ( 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.
I'm not a big fan of this "mode" abstraction, Do you think we could remove it entirely in favor of something like:
onKeyDown( event ) {
const offset = eventToOffset( event ) // check if we use tabs or arrows and returns 1 or -1 or null
if ( ! offset ) {
return;
}
const tabbables = getTabbables() // depending on the deep prop
const currentIndex = tabbables.indexOf( document.activeElement)
const nextIndex = cycle( current, tabbables.lenght, offset ) // apply the cycle prop if necessary
tabbables[ nextIndex ].focus();
}
I'm finding that the "mode" abstraction adds indirection where it's not necessary.
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, this will not support any kind of composition of keyboard navigation, and will not allow grid navigation. Maybe grid navigation isn't as important as I thought it was, but it certainly 'feels' nicer. My recommendation is to play with the InsertMenu PR (#3281) and see what I'm trying to do as a whole. That will give you a better feel about whether this level of indirection is worthwhile. The answer may definitely be "no, not really" :)
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.
Why can't we have something like this while keeping the widget
prop? As I understand it, it applies when trying to get the currentIndex, so instead of const currentIndex = tabbables.indexOf( document.activeElement)
we check the prop and use contains
if it's a widget?
Also, I wonder if we should rename the prop. widget
might not be explicit enough, but It's hard to find a good name. maybe navigateContainers
(I don't really know, just thinking out loud).
Not saying the current implementation is bad, it's not, it's great but you know, I like simple functions ;). I find that if we can get this, it makes it clear, how each prop changes the behavior instead of having to dig to calculateMode
and then get back to the component to understand.
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.
Hmm. Let's explore this.
So mode is responsible for these details:
deep
: used by navigable container
widget
: used by navigable container
initialSelector
used by navigable container, but as you said, we can just make it aria-selected until it is required and drop this altogether
useTabstops
used by navigable container to identify if looking for a tabindex of 0, or any tabindex
detect
: used by navigable container to determine the next index based on keycodes
Note: doing this has shown me that I've got the wrong shape for the fallback return. detect
used to be an array of rules :). I should now return a detect: () => null rather than rules: [ ].
Most of these values (cycle, deep etc.) are passed through as is (though some might have different default values). One minor part that mode is providing is working out whether the focusable targets have a tabindex of -1 (programmatic focus only) or 0 (normal tab focus) via the poorly named useTabstops
prop. The major part that mode is providing is the detect
function. The detect
function has two primary roles:
a. Works out which keys are listened to. This is important. If you don't listen to a key (or stop it), then you enable your ancestors to listen to it, which can help with composing keyboard navigation. E.g. I may not care about tab, but my ancestor might.
b. Works out how to change the index based on which key was pressed. This is particularly important if you want down
to have a different meaning from right
. Or allow home
to jump straight to index 0
and end
straight to total - 1
. This home
and end
capability isn't implemented now, but is mentioned as a possible improvement in the ARIA guidelines for navigating tabs (https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html)
If you wanted to get rid of mode, AND get rid of any potential for any keycode to impact the index by more than +1 or -1, then you would be able condense this down as you suggested. You would lose future flexibility, but the code would be simpler.
If you wanted to get rid of mode, AND have the ability to have more flexible index repositioning with focus (support home, end, down, up etc) then you have two options:
a) provide the detect
function to NavigableContainer
as a prop, and provide useTabstops as a prop.
b) provides a list of keycodes and the index changes they make (originally how rules
worked earlier in the PR), and provide useTabstops as a prop
Essentially, this would be recreating modes
. In (b)'s case, it would probably create more duplication, and it would force people to constantly recreate exactly the same key rules each time (right means +1, left means -1, down means +x, up means -x, home means 0, end means length - 1), and handle cycling themselves for non-trivial cases. All modes are trying to do is encompass common
types of navigation, so that they can be reused easily. I can see how they can be slightly harder to follow, though.
My preference is to stay with modes
, but I can also see how a
above (passing detect
through) would make the code easier to read. I'd be reluctant to fall back to just changing the offset by -1 or +1 depending on whether the key was a 'backward' (left, up) key or a 'forward' (right, down) key --- but maybe my prior experience with more complex navigation requirements is not relevant to Gutenberg.
I'm not sure if we'll be able to convince each other of anything without running code, so it might just be simpler if you alter this to look the way that you want, and we can then compare the advantages and disadvantages of each approach. Does that sound productive?
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.
Although, since the code has been refactored since the initial attempt, the calculateMode code could certainly be done in the exported components themselves (rather than passing in an object for NavigableContainer to transform). Originally, NavigableContainer was going to be exported directly.
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 see two main issues with the detect
function:
- It obscures simpler props
- Composition is made more difficult, thus we loose flexibility instead of gaining flexibility IMO which means If I have a mode, that combines "tabs", "grid" and it's not provided by
calculateMode
it's hard to do unless you pass thedetect
function like you explained, but thedetect
function is not trivial and it can't be an external API of the component IMO.
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.
About the -1/1
value, it was just a simplification because I didn't think we need the grid navigation right now, but if we need it, we can always change the return value of eventToOffset
to accommodate these usecases. ({ x: 1, y: 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.
That won't handle home
and end
jumping to the start and end respectively. But if you're worried about having functions in the API, and you don't want modes, then I guess there isn't any alternative.
Also, the current plan is not to expose |
Interestingly, we can get the focus tests to work in jsdom, as long as we override the getClientRects() method of the DOM elements themselves. The problem as the comment suggests is that the
You can get the focus tests to work (the ones skipped in this PR and the |
Adding this mock is definitely a good idea to avoid skipping the tests 👍 |
@youknowriad I've simplified this trying to implement what you are tasking for. I've also updated the #3281 to use this updated version. I haven't updated the README yet, because I'd rather see if you're happy with this approach first ( / ran out of time :) ) |
|
||
const offset = eventToOffset( event ); | ||
|
||
const stopNavigationKey = ( preventBubblingArrowEvents && [ LEFT, RIGHT, DOWN, UP ].indexOf( keyCode ) > -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.
Could this be simplified like preventBubblingArrowEvents && offset !== 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.
Oh I see, it's relying on preventBubblingTabEvents
or preventBubblingArrowEvents
which make things a bit different. Can't we use the same prop? preventBubblingNavigationEvents
?
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.
They can't use the same prop. Think of the Inserter Example
- TabbingCycle
- Input
- TabList (cares about arrows)
- tab1
- tab2
- TabView (cares about arrows)
- item1
- item2
If the TabList (which needs to stop arrow keys bubbling also stopped tab keys bubbling), then the TabbingCycle wouldn't work. The TabbingCycle needs to receive the tab key, otherwise it can't force tab to cycle within itself. They need to be separate. Does that make sense?
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 had to rename the prop because we need to preventDefault
in many situations as well, not just stopPropagation
.
|
||
export class NavigableMenu extends Component { | ||
render() { | ||
const { deep = true, cycle = true, orientation = 'vertical', ...rest } = this.props; |
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 we drop deep
and cycle
from here, and rely on ...props
to pass them down. Same for TabbableContainer
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 think this was leftover from when there were different defaults (or information was needed for modes).
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 looks pretty solid to me. Thanks for the hard work. I left some small notes but we're pretty close to the merge
@youknowriad anything else? |
- Required: No | ||
- default: true | ||
|
||
## handleRef |
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 clarify why we need this prop. I'd love if we can avoid it since it's too "technical".
<div tabIndex="0">Section 1</div> | ||
<div tabIndex="0">Section 2</div> | ||
<div tabIndex="0">Section 3</div> | ||
<div tabIndex="0">Section 4</div> |
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.
mixed space/tabs indentation
@@ -146,22 +139,21 @@ export class NavigableMenu extends Component { | |||
return 0; | |||
}; | |||
|
|||
return <NavigableContainer deep={ deep } cycle={ cycle } eventToOffset={ eventToOffset } { ...rest } />; | |||
return <NavigableContainer stopArrowEvents={ true } stopTabEvents={ false } |
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.
Minor, you can drop the ={ true }
for all props with a true value.
|
||
function handleHorizontalArrows( event ) { | ||
// Do not preventDefault if inside a textarea or input where the arrow keys should move inside it. | ||
if ( ! [ 'textarea', 'input' ].indexOf( event.target.nodeName.toLowerCase() ) ) { |
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 about contenteditable
.
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 probably came to the conclusion that you did below that no-one would want a contenteditable inside an arrowable region. Having said that, I have no idea why I thought textarea and input were more likely :)
|
||
if ( stopArrowEvents && [ LEFT, RIGHT ].indexOf( keyCode ) > -1 ) { | ||
handleHorizontalArrows( event ); | ||
} else if ( stopArrowEvents && [ UP, DOWN ].indexOf( keyCode ) > -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.
Why UP/DOWN
are different than LEFT/RIGHT
both need to move cursor in inputs/textarea right?
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. Just confused myself. Up and down you don't typically use in a single line input, but you certainly would in a textarea.
} else if ( stopTabEvents && TAB === keyCode ) { | ||
stopEvent( 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.
Thinking about this more. I'm wondering why we're not systematically preventing and stopPropagation consistently.
I mean if we're using arrows
in a navigable menu, we expect these arrows to navigate to other elements, thus, we need to preventEverything. And if we're using tabs
same.
By this I mean, it makes no sense to me to use arrow navigation if your children could contain inputs/textareas...
which brings us to my simple check: preventBubblingEvents && offset !== 0
. This would work because if use arrows, the offset will be different than 0
only for arrow keys and same for tabs.
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.
Hmm. Interesting idea. I have no problem with this but it would mean that the person writing the offset function would need to understand that returning 0 for an offset, even if it is just because they have hit the edge, means that the event will not be handled and will bubble up (which causes problems when things like WritingFlow listen to arrow keys above). I guess given that cycle is done here (and not done by the offset calculation code which could return 0 at the edges), then the offset function will just have to be clear that you do not return 0 unless you want the event to bubble.
My personal style is to avoid conflating stopping the event and moving the focus, but I can see the benefits (ensure the two are kept in sync). This is done most easily by having a complicated function, so I can see why that is undesirable in the API. Your approach will work if you work on the assumption that offset 0 means do not bubble.
Another problem with this is that it means that for situations where you have a horizontal menu (left, right arrow keys), pressing up and down will have an offset of 0, so they will bubble to WritingFlow and cause problems. Initially, I introduced this so that even the arrow keys that weren't involved in moving the focus were being stopped. Otherwise, the other keys (up instead of left) etc. are going to bubble.
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.
As discussed in DM, your idea of returning undefined
for do not stop this event, and 0
for stop it, but don't move seems good to me.
Codecov Report
@@ Coverage Diff @@
## master #3303 +/- ##
=========================================
+ Coverage 31.34% 31.94% +0.6%
=========================================
Files 246 246
Lines 6779 6814 +35
Branches 1220 1232 +12
=========================================
+ Hits 2125 2177 +52
+ Misses 3914 3906 -8
+ Partials 740 731 -9
Continue to review full report at Codecov.
|
3096e3a
to
2f6f1c8
Compare
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.
Nice work
const { deep = false, onlyBrowserTabstops } = this.props; | ||
const finder = onlyBrowserTabstops ? focus.tabbable : focus.focusable; | ||
const focusables = finder | ||
.find( this.container ) |
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.
Array#find
is not polyfilled and is not supported by IE11. This results in crashes in IE11 (e.g. opening inserter).
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.
Nevermind, this is the focus utility find
method 🤦♂️ There's still an error, but I think it's caused by https://github.com/WordPress/gutenberg/pull/3281/files#r151239536.
'onlyBrowserTabstops', | ||
] ) } | ||
onKeyDown={ this.onKeyDown } | ||
onFocus={ this.onFocus }> |
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 this not doing anything?
Edit: And if it's not, should it be? (i.e. updating offset from receiving focus)
const { children, ...props } = this.props; | ||
|
||
// Disable reason: Assumed role is applied by parent via props spread. | ||
/* eslint-disable jsx-a11y/no-static-element-interactions */ |
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 rule is disabled but then never turned on again (meaning it will take effect for the remainder of the file).
Description
This is based on the work from #3281. I'm just pulling out the NavigableContainer part and adding a README and some data tests (and some skipped UI tests).
How Has This Been Tested?
Manually, and there are some automated tests for the data transformations and movement.
Screenshots (jpeg or gifs if applicable):
Types of changes
Checklist: