Skip to content
This repository has been archived by the owner on May 24, 2024. It is now read-only.

Compact interactive list keyboard navigation #1919

Merged
merged 67 commits into from
Dec 13, 2023

Conversation

adoroshk
Copy link
Contributor

@adoroshk adoroshk commented Nov 30, 2023

Summary

This PR adds keyboard navigation to compact interactive list component.

What was changed:
Following functionality has been added:

  • The list is a single roving tab stop.

  • Right/Left Arrows navigate across visual rows. When focus hits the end of the visual row navigation stops.

  • Up/Down Arrows navigate across visual columns. Navigating down from the bottom of a visual column takes you to the corresponding cell at the top of the next visual column and vice versa (except for the last and first items).

  • Moves to the first cell in the focused visual row

    • Windows
      • Home
    • Mac
      • Home
      • Fn + Left Arrow (equivalent to Home)
      • Cmd + Left Arrow
  • Moves to the last cell in the focused visual row

    • Windows
      • End
    • Mac
      • End
      • Fn + Right Arrow (equivalent to End)
      • Cmd + Right Arrow
  • Moves to the first cell in the first item (top left)

    • Windows
      • Ctrl + Home
    • Mac
      • Cmd or Ctrl + Home
      • Cmd or Ctrl + Fn + Left Arrow (equivalent to Home)
      • Cmd + Ctrl + Left Arrow
  • Moves to the last cell in the last item (bottom item in rightmost visual column)

    • Windows
      • Ctrl + End
    • Mac
      • Cmd or Ctrl + End
      • Cmd or Ctrl + Fn + Right Arrow (equivalent to End)
      • Cmd + Ctrl + Right Arrow
  • If cell has an interactive element (only one per cell allowed), focus is set to that element, not the cell (only one interactive element per cell allowed) and the arrow keys behavior is default to that element (NOTE: currently it causes problems with input elements that won't allow to navigate away from that with keyboard, a decision needs to be maid by UX team on what is the best way around that).

  • Cells are select-able by Space key (it triggers onClick for cell) unless the cell has an interactable element within, in that case its not selectable neither via mouse click nor per space key.

Testing

Note: at that moment tests are work in progress.
This change was tested using:

  • WDIO
  • Jest
  • Visual testing (please attach a screenshot or recording)
  • Other (please describe below)
  • No tests are needed

Reviews

In addition to engineering reviews, this PR needs:

  • UX review
  • Accessibility review
  • Functional review

Additional Details

This PR resolves:

UXPLATFORM-9788

Comment on lines 102 to 107
columnMinimumWidth: PropTypes.string,

/**
* Columns maximum width should be a valid css string in value in px, em, or rem units..
* Columns maximum width should be a valid css string in value in px, em, or rem units.
*/
columnMaximumWidth: PropTypes.string,
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't the minimum and maximum widths be part of the columnShape? I would expect to be able to control the values for each column.

Copy link
Contributor Author

@adoroshk adoroshk Dec 11, 2023

Choose a reason for hiding this comment

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

There are 2 ways to control column max and min width:
Option 1: specify min and max width for a specific column as a part of column shape prop:


Option 2: (the one you are looking at) columnMaximumWidth and columnMinimumWidth prop of the whole component, this way they'll be applied to each column.

Option 1 takes precedence on Option 2, so if there is columnMaximumWidth set to the whole component and hence applied to all columns, but then some columns have minimumWidth (or maximumWidth) props set for these columns directly, the last ones would be applied to those columns.

@eawww
Copy link
Contributor

eawww commented Dec 11, 2023

@mjpalazzo @adoroshk I wouldn't want to give overly verbose instructions detailing every possible interaction, especially since that also needs to be available to non screen reader users, but something like "Use arrow keys to navigate and space or enter to activate a cell" would probably be helpful.
We still need to make the external instructions components for all these grids.

const onFocus = (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
// Not triggered when swapping focus between children
focusCell(focusedCell.current);
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens if the list was updated while focus was lost. You will end up with a similar exception that we had to fix for the WorklistDataGrid.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. In this commit I changed the focused cell data to row and column ids instead of indexes: 396ed59

Comment on lines 9 to 13
export const getFocusableElements = (parentElement) => [...parentElement.querySelectorAll(`${focusableElementSelector}`)].filter(
element => !element.hasAttribute('disabled')
&& !element.getAttribute('aria-hidden')
&& window.getComputedStyle(element).visibility !== 'hidden',
);
Copy link
Contributor

Choose a reason for hiding this comment

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

We have a similar method in focusManagement.js in the Table component. I would think we would not want to duplicate it all. Same comment for the other methods.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually it's a bit different. I have simplified this one a bit as the one you are referring to checks for offsets for cases when components are not hidden but rather moved outside of the container to not be visible for sighted user. It was messing with jest enzyme tests and as I don't expect the cell having that king of "hidden" elements, I simplified it.

Copy link
Contributor

Choose a reason for hiding this comment

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

You should not need to change it. You just need to update your jest tests appropriately. If you check the other jest tests, there is code to handle providing an offset value. The logic should work for your component.

Copy link
Contributor Author

@adoroshk adoroshk Dec 12, 2023

Choose a reason for hiding this comment

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

@cm9361 huge thank you for the help with tests to accommodate the full original logic from terra-table. I fixed the tests and currently added getFocusableElements is the original one that is implemented in terra-table.

In order to reuse the same logic rather than copy-paste it, I added the export for the getFocusableElements method from the terra-table. Once the terra-table is released, I will remove copied getFocusableElements method from the compact interactive list and attach the one exported from the terra-table. Would that work?
The commit: cdbf56a

Copy link
Contributor

Choose a reason for hiding this comment

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

You can use a relative path instead of having to add a package dependency. That logic was released in terra-table quite a while ago though, if I am not mistaken.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correct, imported from terra-table: e7343db

/**
* The cell's column position in the table. This is zero based.
*/
columnIndex: PropTypes.number,
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't the cell also have a column id?

Copy link
Contributor Author

@adoroshk adoroshk Dec 11, 2023

Choose a reason for hiding this comment

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

UPD: there is a full column info passed a s a column prop (needed for cell max/min width, etc). It also has a column id.

const handleKeyDown = (event) => {
// does not need setFocusedCell call as the cell focused by key is tracked in CompactInteractiveList component
const key = event.keyCode;
if (key === KeyCode.KEY_SPACE && isSelectableCell) {
Copy link
Contributor

Choose a reason for hiding this comment

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

So a cell cannot be selected if it doesn't have interactable elements?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Per @eawww at that moment (unless it changes in the future) the cell can NOT be selected If there is an interactable element in it (and there can be only one intreactive element per cell).

return focusedCell;
}
// focus should go to the next semantic row unless the last row
return { row: row + 1 < rowsLength ? row + 1 : row, cell };
Copy link
Contributor

Choose a reason for hiding this comment

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

You probably could simplify this by using the Math.min function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure I got how to do that. An example will be appreciated.

Copy link
Contributor

@eawww eawww left a comment

Choose a reason for hiding this comment

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

Thank you @adoroshk! It works fantastically.

One last request

Could you add to the prop description for numberOfColumns: "Number of rows is calculated as the number of items divided by the number of columns, rounded up." or if there's a more accurate wording, that'd be cool.

@adoroshk
Copy link
Contributor Author

Thank you @adoroshk! It works fantastically.

One last request

Could you add to the prop description for numberOfColumns: "Number of rows is calculated as the number of items divided by the number of columns, rounded up." or if there's a more accurate wording, that'd be cool.

@eawww added the description here:

* A number of visual columns. Defaults to 1. Number of visual rows is calculated as the number of items divided by the number of columns, rounded up.

Copy link
Contributor

@kenk2 kenk2 left a comment

Choose a reason for hiding this comment

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

Just a nit and a question, but otherwise looks good.

* @param {number} rowsLength - a total number of seamntic rows in the list.
* @returns - a { row, cell } indexes pair to focus on.
*/
export const handleDownKey = (focusedCell, numberOfColumns, flowHorizontally, rowsLength) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I'm wondering if there was a way to not have to delegate all the directional keys to a unique function. I see that in each handleXKey function, we're re-stating a lot of the same logic and params across each function. In the DataGrid, we generally were able to keep all the directional handles in the same function.

Up to you on how/when/if to address this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I understand your concern, I'll look into shared logic between these methods and see if it can be improved in my next PR, this one gets too complex to monitor the changes.

event.preventDefault();
break;
case KeyCode.KEY_LEFT: {
moveFocusTo = handleLeftKey(event, moveFocusTo, numberOfColumns, flowHorizontally, columns.length, rows.length);
Copy link
Contributor

@kenk2 kenk2 Dec 12, 2023

Choose a reason for hiding this comment

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

Do we need to event.preventDefault() on the other navigation actions? I would think so as all of these are related to navigation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch on preventDefault inconsistency. Actually, we don't need the preventDefault on Up and Down keys, as there is one preventDefault for all cases after the switch. Escape and Tab bave return at the end, so they need their own preventDefaults, but arrow keys have break, not return and go to the end of the method to reach the preventDefault there and I just missed that. I removed extra in this commit: a6bcd2f

@github-actions github-actions bot temporarily deployed to preview-pr-1919 December 12, 2023 23:39 Destroyed
@adoroshk adoroshk merged commit 5dc1d7e into main Dec 13, 2023
21 checks passed
@adoroshk adoroshk deleted the compact_interactive_list_keyboard_navigation branch December 13, 2023 06:35
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants