-
Notifications
You must be signed in to change notification settings - Fork 8
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
Table keyboard interaction (draft / partial implementation) #2070
Conversation
* Gets the child elements in this cell view that should be able to be reached via Tab/ Shift-Tab, | ||
* if any. | ||
*/ | ||
public get tabbableChildren(): HTMLElement[] { |
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.
If a table column type will contain focusable/tabbable elements, our guidance would be:
- That column's cell view overrides this property, and returns the focusable element(s) in the array
- The focusable elements should be tabindex=-1 by default
KeyboardNavigationManager
uses this when handling Tab/Shift-Tab for a given row, and to reflect the correct focus state if one of these elements is focused (TableFocusType.cellContent
).
Currently KeyboardNavigationManager
updates tabindex of these elements directly (except for the tabIndexOverride
cases described previously). For arbitrary cell content, this might not be sufficient, since the element that should be tabindex=0 could be an element descendant in a shadow root or something similar. So we may need to add an additional API/method to handle focusing/unfocusing these children.
} | ||
|
||
/* TODO: Scope this to only hasDataHierarchy; ~4px more padding on left, so border doesn't touch expand/collapse button border */ | ||
nimble-table-cell:first-of-type${focusVisible} { |
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: These 2 rules are handing the special case of making the cell border for the 1st column / hierarchy column wide enough to include the expand/collapse button. It probably needs another cleanup pass - I couldn't come up with something that worked with just margin/padding on the cell, but I got something working adding a ::before
pseudoselector too).
@@ -33,6 +33,7 @@ import { | |||
export const template = html<Table>` | |||
<template | |||
role="treegrid" | |||
tabindex="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.
As everything in the table will now start as tabindex=-1, this tabindex=0 ensures the table can be keyboard focused initially. The table remains as tabindex=0 for almost all (see KeyboardNavigationManager.blurAfterLastTab()
for an exception)
table.addEventListener('keydown', e => this.onKeyDown(e)); | ||
table.addEventListener('focusin', e => this.handleFocus(e)); | ||
table.addEventListener('focusout', e => { | ||
console.log( |
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.
(These console.log
s are just temporary/for debugging, for anyone that wants to get a better sense of what's getting focused/unfocused while interacting with the table in Storybook. It's not always obvious what's getting focused.)
} | ||
} | ||
|
||
public onVirtualizerChange(): void { |
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 is how we handle re-focusing the appropriate row after scrolling / virtualizer changes. Observing the virtualizer.visibleItems
is another option, but we'd also need to observe each individual row and monitor for dataIndex
changes.
Possibly keeping a reference to the currently focused row (so we could just watch for dataIndex
changes) is another option.
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.
Noticed while demoing to Milan / Meyer today that this simplified approach has issues once you start scrolling down with just DownArrow (you end up with no focused row visible).
Adding a rAF works but is hacky, so I'll probably switch back to the original approach mentioned in the comment above.
} | ||
|
||
private readonly handleFocus = (event: FocusEvent): void => { | ||
// User may have clicked elsewhere in the table (on an element not reflected in this.focusState). Update our focusState |
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 indicated by the code comments, I'm not super satisfied with all of this logic being in the focusin
handler, I think it makes it harder to reason about the various focus/unfocusing states of the table.
If we only had to worry about keyboard navigation by itself (without mouse interactions), the responsibilities of this class would be pretty simple - basically just a state machine: current focus state + a key press => new focus state
.
But in practice, the user can click anywhere on the table (like interacting with an action menu), and then we still want keyboard navigation to behave in a sane way.
Here's 2 action menu scenarios that behave differently, that led to the current logic in this function:
- Table is not focused at all, then the user clicks a cell action menu button. Result: first the
MenuButton
is the active element, then aMenuItem
is active (once the menu is open) - Table is focused (via Tab), the 1st row is focused (Down Arrow), then the user clicks the action menu button on some other row (not the 1st). Result: the
MenuItem
is active. I didn't see any focus events get triggered with the actualMenuButton
in this scenario, which is why I have code going from aMenuItem
back to the linked action menu.
This function also handles setting the appropriate element in the table on initial focus, so there's a fair amount going on in this function.
I think there might be better ways to handle all of this, so I'm looking for suggestions on different approaches here 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.
A simple improvement might be to do a "clean code" pass and see if you can move each of the if
blocks into their own well-named functions.
Another idea is to make focusState
into a class rather than an object with individual fields being mutated. That might help make the state transitions more explicit. This might look something like:
- make all the fields of the class
readonly
/getters from the outside - add public methods to set state in batches. e.g.
focusCellActionMenu(rowIndex, columnIndex) {
this.focusType = TableFocusType.cellActionMenu;
this.rowIndex = rowIndex;
this.columnIndex = columnIndex;
}
- see if those public methods can enable this class to be even more explicit about state transitions. Maybe each one can be called from a navigation manager method that also updates the navigation manager's state.
I only looked at a couple of state transitions so I'm not sure how much this would help, might require prototyping one or two states in a branch to see if we like it better.
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 see Meyer made similar suggestions in https://github.com/ni/nimble/pull/2070/files#r1591068527
// Note: In Chrome this is only needed for Shift-Tab, but in Firefox both Tab+Shift-Tab need this | ||
// to work as expected. | ||
this.table.tabIndex = -1; | ||
window.requestAnimationFrame(() => { |
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.
requestAnimationFrame
calls always need vetting/questioning, so opening a comment thread here. See code comments for why this is here (if the table was still tabindex=0 when the user did Shift-Tab, the overall table would get focused which isn't what we want).
I'm also not aware of a good way to determine what the next or previous focused element will be, if we wanted to focus it programmatically (the browser computes it based on semi-complicated tab order rules).
So this code is just trying to ensure the browser can handle it: we blur the focused table sub-element, and then we don't preventDefault()
on the Tab/Shift-Tab key event, so the browser will do the default behavior still (of focusing the next/previous element in tab order).
@rajsite @jattasNI @atmgrifter00 I think I've now commented on the main areas of this code that I'd like feedback on, so feel free to start looking it over now. If there's any demos/walkthroughs of it that you'd like to see to understand something better (or any discussions that you think would be better as Teams calls) we can do that next week. Edit: Any feedback on overall implementation approach, ways to simplify the code, etc, are welcome too at this point. For example all the key press handlers (like |
} | ||
} | ||
|
||
if (event.target instanceof MenuItem) { |
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 guessing it's not possible to enter into this if
block if the above if
block was entered? If not, can we make it more clear by making this an if/else if
block? If it can make sense to enter into both if
blocks, are there ways for us to avoid duplicate efforts (possibly an over-optimization as iterating over the columns isn't a big deal).
packages/nimble-components/src/table/models/keyboard-navigation-manager.ts
Outdated
Show resolved
Hide resolved
} | ||
while ( | ||
cellIndex >= 0 | ||
&& cellIndex < this.table.visibleColumns.length |
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 make a couple of private helper methods to help with code readability (reduce function length). Something like setNextTabFocusState(cellIndex: number)
and setPreviousTabFocusState(...)
? Possibly even getNextTabCellIndex
and getPreviousTabCellIndex
that just encapsulates the switch logic.
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.
Agreed. I'll want to do another pass on code sharing/ helper methods if people are otherwise satisfied with the general approach on tracking focus state that I have here. I wanted to see if there were any large course corrections needed first 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.
I'm ok with the overall architecture of managing the state from this class. For me the state machine readability is the biggest course correction. I left some brainstorm-y ideas on that in this thread: https://github.com/ni/nimble/pull/2070/files#r1595622740
packages/nimble-components/src/table/models/keyboard-navigation-manager.ts
Outdated
Show resolved
Hide resolved
@@ -0,0 +1,1025 @@ | |||
/* eslint-disable no-console */ |
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.
Here are the set of behaviors that I noticed that I believe are not ideal (unexpected):
Table with hierarchy data (single selection mode):
- If the first action menu has focus, I think pressing Shift-Tab should focus the row.
- If an action menu has focus (or any focusable element), pressing the Left or Right arrow keys should shift focus to the previous or next cells respectively if there are no other focusable elements in the cell.
- If the table has ever moved focus to something like an action menu, then after you click out of the table, and then tab to it, it will skip the header content and refocus the action menu. This means that it is no longer easy to just tab past the table at the top-level.
Table Anchor Column
- The anchor column cells do not seem like they can show the highlighted cell border. I admit that this may be intentional, and not sure if there is a better answer.
Possibly more to come...
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.
Anchor Column: Intentional. This is the case of a cell containing a single focusable control, in which case we only focus the control, not the cell (according to the HLD).
I think our current anchor focus styling makes it a bit difficult to tell when the anchor is focused (at least compared to the focused cell styling), so we might end up revisiting that behavior. (I can demo it to the team and get opinions)
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 sure if I agree on the 1st 3 points (behavior after an action menu is focused). To summarize the current behavior:
Once an action menu is focused, you're no longer in row/cell navigation mode. You can return to that mode by pressing Esc, though. I'd characterize being able to press LeftArrow / RightArrow to go from a focused action menu to cell focus again as a behavior change from what's in the HLD, but if others agree I can look at adding that.
If the table has ever moved focus to something like an action menu, then after you click out of the table, and then tab to it, it will skip the header content and refocus the action menu. This means that it is no longer easy to just tab past the table at the top-level.
I'm not sure how I'd reconcile that suggestion with our desire to return to the previously focused row once you tab past the table, and then tab back to it. Currently I'm characterizing interacting with the action menu the same way as interacting with a row/cell (in that we want to return focus to that same place when tabbing past and returning). I'm curious if others have an opinion on this point.
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.
our desire to return to the previously focused row once you tab past the table, and then tab back to it
I think that workflow is important. It'd be annoying to navigate through a bunch of rows and columns to get to the cell you want, then accidentally press tab or click one extra time causing the table to lose focus and all your navigation to be lost.
I'm ok with the current behavior, but would be open to ideas for returning the table to its initial state where it only has one tab stop. Right now the way I know to do it is to reverse all the navigation that got you to your current state (ESC to unfocus action menu, arrows to return focus to row / header). As an example idea, maybe if you keep pressing ESC the focus should move up the hierarchy (action menu -> cell -> row -> header)?
But I'm wary of designing this on the fly and increasing scope too much before we get this in the hands of actual users.
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, I'm okay with the tab behavior. More than anything I wanted to check that we were fine with the Table
entering into a state where it is no longer to just tab past it at the top-level. The AzDO table avoids this by not letting you tab through focusable elements in a row, and instead relies on the arrow keys (as it does not let you put cell focus in columns with no focusable elements). Screen readers like NVDA allow a user to navigate to non-focusable columns with Ctl-Arrow keys, which does not work without an active screen reader.
This example, (which I recognize is just some jo-shmo's idea of a treegrid) does what is currently implemented in ours.
For the first point on Shift-Tab
putting focus on the row when the first action menu is focused, I think I just needed to become accustomed to pressing Esc
to re-enter navigation mode.
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.
Early feedback from some storybook testing. Haven't looked at the code much yet.
@@ -1,4 +1,4 @@ | |||
import { attr } from '@microsoft/fast-element'; | |||
import { attr, observable } from '@microsoft/fast-element'; |
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 able to get row selection to work. From the HLD:
If a row is focused, pressing Space will select/unselect that row
When I focus an entire row, space either does nothing or scrolls the page. I tried this with both single select and multi select mode on the hierarchical table (again in macOS Firefox).
In multiselect mode I am able to focus the selection checkbox and press Space to toggle 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.
Just not yet implemented (updated the PR description), but should be straightforward.
@@ -28,11 +28,23 @@ const metadata: Meta<BaseTableArgs> = { | |||
parameters: { | |||
actions: { | |||
handles: [ | |||
/* |
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.
Shot in the dark, but I recall an issue with the Actions add-on where it can get broken / slow trying to print large / complex event data. I think I recall fixing it by limiting the depth of the event data serialization. I can help search for that if you think that might be the issue and can't discover how to configure it on your own.
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 guessing this is referencing the configureActions({depth: 1}) here in the Storybook project, but changing it to depth=0 doesn't get rid of the issue.
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, that's what I was recalling. Too bad it doesn't help π
// via keyboard, to ensure they're visible to focus. This code ensures we remove that CSS class when the menu | ||
// button is no longer focused (which may not be for a keyboard nav reason, i.e. a mouse click elsewhere on | ||
// the table) | ||
this.actionMenuButton!.classList.remove('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.
This is an implicit coupling between the table cell and the keyboard navigation manager which seems likely to get out of date if we change the keyboard manager implementation (e.g. rename this class). Is there anything we can do to make it more explicit and keep the knowledge of the focused
class within one file?
For example, could the cell fire an event when the action menu is blurred and the keyboard navigation manager listen for that event?
@@ -0,0 +1,1025 @@ | |||
/* eslint-disable no-console */ |
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.
our desire to return to the previously focused row once you tab past the table, and then tab back to it
I think that workflow is important. It'd be annoying to navigate through a bunch of rows and columns to get to the cell you want, then accidentally press tab or click one extra time causing the table to lose focus and all your navigation to be lost.
I'm ok with the current behavior, but would be open to ideas for returning the table to its initial state where it only has one tab stop. Right now the way I know to do it is to reverse all the navigation that got you to your current state (ESC to unfocus action menu, arrows to return focus to row / header). As an example idea, maybe if you keep pressing ESC the focus should move up the hierarchy (action menu -> cell -> row -> header)?
But I'm wary of designing this on the fly and increasing scope too much before we get this in the hands of actual users.
β¦wport tabstop (Firefox)
β¦c as Tab for rows/cells)
- If an anchor is focused in a cell, and we're in the 'single interactive element' special case, and the user mousewheel scrolls the table, the row will be focused instead. (So we don't try to refocus the anchor while the focusedRecycleCallback blurs it) - If cell contents were focused (tabbable child), and a scroll occurs, the cell will be focused instead. Also ensure action menu focus state is set once an action menu is opened. - If an action menu button was focused and a scroll occurs, the cell will be focused instead.
Pull Request
π€¨ Rationale
#1137 (HLD)
Storybook build for this branch
π©βπ» Implementation
The
KeyboardNavigationManager
class does the bulk of the work:focusState
property which containsfocusType
- there are multipleTableFocusType
values likecolumnHeader
/row
/cell
/cellActionMenu
/etc)focusState
would end up out-of-date. There's code inhandleFocus()
to handle for this (see additional comments there)handleFocus()
again)The overall approach of only setting tabindex=0 on the single element in the table we want focused is called roving tabindex.