Skip to content

Fix table keyboard navigation bugs#1993

Merged
LFDanLu merged 13 commits into
mainfrom
issue_1208
Jul 27, 2021
Merged

Fix table keyboard navigation bugs#1993
LFDanLu merged 13 commits into
mainfrom
issue_1208

Conversation

@LFDanLu
Copy link
Copy Markdown
Member

@LFDanLu LFDanLu commented Jun 8, 2021

Closes #1208

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

Comment on lines +60 to +70
// TODO: This conflicts with the childFocusStrategy stuff set up below, but this behavior made more sense to me
// since it respects visual tab order by default.
// Perhaps state.selectionManager.childFocusStrategy should default to "undef" in useMultipleSelectionState
// so that I can do a childFocusStategy != null check here so it doesn't override what the user sets?

// If the current active element is a focusable child within the cell, early return.
// This means that we've alt/option tab forward/backward into the cell and we should preserve the current
// focused child element.
if (document.activeElement !== ref.current && ref.current.contains(document.activeElement)) {
return;
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Open question here. Not entirely sure what the desired behavior here is with childFocusStrategy, kinda feel like useMultipleSelectionState shouldn't have a default focus strategy, instead setting it to null similar to how useMenuTriggerState does it. Then I could check a child focus strategy exists and perform the early return if one doesn't, allowing shift+option+tab to follow the visual tab order (currently behaves this way w/o these fixes)

Comment thread packages/@react-aria/selection/src/useSelectableCollection.ts Outdated
Comment on lines +148 to +158
} else if (e.target !== ref.current && focusedKey != null && !isFocusWithin.current) {
let focusedView = virtualizer.getView(focusedKey);
// Otherwise if we are re-entering the collection and there is a previously focused key that is out of view, scroll it into view
// If it is in view already, preserve the current scroll position via scrollTo. This is to prevent the shift-tabbing into the
// collection from scrolling the focused key out of view due to useSelectableCollection focusing the last element in the table when tabbing out of the collection
if (!focusedView) {
virtualizer.scrollToItem(focusedKey, {duration: 0});
} else {
let rect = virtualizer.getVisibleRect();
virtualizer.scrollTo(new Point(rect.x, rect.y), 0);
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixes "When I shift tab back into the table, it does another page down"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is the root cause here that the focused key actually changes when tabbing out of the table due to focusing the last focusable item? Should we instead prevent this from happening?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

So the focused key actually doesn't change when tabbing out of the table because the last focusable item is actually a checkbox for a row that is out of view (useGridCell actually doesn't update the focusedKey in this case because we are in keyboard modality and useSelectableItem doesn't either because e.target isn't the cell itself).

The root cause of the weird scrolling behavior from shift tabbing into the table is because the browser is moving focus to the last focusable element in the table which is the out of view checkbox, causing the scroll...

Copy link
Copy Markdown
Member Author

@LFDanLu LFDanLu Jul 22, 2021

Choose a reason for hiding this comment

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

Tried a bunch of stuff but this still feels like the best solution since it fixes the following two cases:

  • stopping the table from scrolling to the last checkbox when shift tabbing back into the table
  • properly scrolling a item back into view when tabbing into the table

The second one can be reproduced by doing the following:

  1. focus a cell in the table by clicking/tabbing into the table
  2. using your mouse/touchpad, scroll the cell just out of view by scrolling down a little bit (make sure not to scroll so far that the cell is removed from the DOM)
  3. click on an element outside of the table
  4. tab back into the table

Without the above code, tabbing back into the table would focus the first focusable item, aka the column's select all checkbox which results in the focused cell in the table not being scrolled back into view.

Any suggestions? I'm not sure how we would be able to pull this out of virtualizer

Copy link
Copy Markdown
Member Author

@LFDanLu LFDanLu Jul 23, 2021

Choose a reason for hiding this comment

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

An alternative would be to apply excludeFromTabOrder to the props returned by useTableSelectionCheckbox and useTableSelectAllCheckbox so that tabbing/shift-tabbing into the table moves focus to the previously focused cell that has tabindex=0.

EDIT: this doesn't take into account what cells might be passed to the user unfortunately, same problem will happen if any of the cell children have tabbable content. Ideally items that are out of visible view should have tabIndex = -1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A possible solution that could replace the Virtualizer change here is perhaps something like this?

  useEffect(() => {
    let onKeyDown = (e) => {
      // The consts here can be removed if we export them from FocusScope
      const focusableElements = [
        'input:not([disabled]):not([type=hidden])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        'button:not([disabled])',
        'a[href]',
        'area[href]',
        'summary',
        'iframe',
        'object',
        'embed',
        'audio[controls]',
        'video[controls]',
        '[contenteditable]'
      ];

      focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
      const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');

      if (e.key === 'Tab' && !ref.current.contains(e.target as HTMLElement)) {
        let tabbableElements = Array.from(document.querySelectorAll(TABBABLE_ELEMENT_SELECTOR));
        let index = tabbableElements.findIndex(node => node === e.target)
        let nodeToFocus;
        if (e.shiftKey) {
          nodeToFocus = tabbableElements[index - 1];
        } else {
          nodeToFocus = tabbableElements[index + 1];
        }

        if (ref.current.contains(nodeToFocus) && manager.focusedKey) {
          e.preventDefault();
          manager.setFocused(true);
          focusSafely(ref.current);
        }
      }
    }

    window.addEventListener('keydown', onKeyDown, true);
    return () => {
      window.removeEventListener('keydown', onKeyDown, true);
    }
  }, [])

Essentially we capture all keypresses that happen outside of the TableView. If we detect that the key press is Tab/Shift + Tab we then determine if focus would've shifted into the TableView and if so, prevent default behavior of the Tab key down so the browser doesn't shift the scroll position and then focus the TableView itself. The TableView then handles focusing the previously focused key itself via its existing useVirtualizer code (if selectedKey is out of view) or the useSelectableItem code (if selectedKey is already in view)

Copy link
Copy Markdown
Member Author

@LFDanLu LFDanLu Jul 26, 2021

Choose a reason for hiding this comment

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

Another idea is to mimic some of the code in useSelectableList used for scrolling the focusedKey into view: https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/selection/src/useSelectableList.ts#L114-L121. We could add similiar scrollIntoView in a capturing onFocus listener that detects when the focus is moved into the Table from outside. If so, then we bring the focused item into view but only if it isn't in view already and if isVirtualized is false. For the virtualized case, we keep the changes already made in this PR.

As an aside, we might want to also have the aforementioned code from useSelectableList added to useGrid since the table hooks don't automatically move the focused key into view as the user navigates via keyboard

Quick and dirty example code, might live in useGrid/useTable? Also might change the api of those hooks depending on what info is available :

  // Move focused key into view as it changes
  let bodyRef = useRef();
  React.useEffect(() => {
    // Would also need to check if !isVirtualized
    if (state.selectionManager.focusedKey && bodyRef?.current) {
      let element = bodyRef.current.querySelector(`[data-key="${state.selectionManager.focusedKey}"]`) as HTMLElement;
      if (element) {
        // This func comes from useSelectableList, not part of the snippet
        scrollIntoView(bodyRef.current, element);
      }
    }
  }, [bodyRef, state.selectionManager.focusedKey]);

  let prevScroll = useRef(bodyRef.current?.scrollTop || 0);
  // Bring focused key into view if focus enters the table from outside
  React.useEffect(() => {
    let onFocus = (e) => {
      if (ref.current?.contains(e.target) && (!ref.current?.contains(e.relatedTarget))) {
        if (state.selectionManager.state.focusedKey) {
          let element = bodyRef.current.querySelector(`[data-key="${state.selectionManager.focusedKey}"]`) as HTMLElement;
          if (element) {
            // Need to track previous scroll position, but at the same time need to override the scroll position
            let scrollContainerTop = bodyRef.current.offsetTop + prevScroll.current
            let scrollContainerBottom = bodyRef.current.offsetTop +prevScroll.current + bodyRef.current.offsetHeight
            let elementBottom = element.offsetTop + element.clientHeight;
            // If focused key is out of view, scroll it into view when re-entering the table
            if (!(elementBottom > scrollContainerTop && elementBottom < scrollContainerBottom)) {
              scrollIntoView(bodyRef.current, element);
            } else {
              // If focusedkey is already in view, override the scroll that may happen from shift tabbing (browser will focus last focusable element in the table which may be out of view, causing a scroll)
              bodyRef.current.scrollTop = prevScroll.current
            }
          }
        }
      }
    }

    window.addEventListener('focus', onFocus, true);
    return () => {
      window.removeEventListener('focus', onFocus, true);
    }
  }, [state, bodyRef.current])

  // Save the previous scroll position
  let onScroll = React.useCallback(() => {
    prevScroll.current = bodyRef.current.scrollTop;
  }, [bodyRef]);

Comment thread packages/@react-aria/grid/src/useGridCell.ts
Comment thread packages/@react-aria/grid/src/useGridCell.ts Outdated
@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

Comment thread packages/@react-spectrum/table/test/Table.test.js Outdated
@LFDanLu LFDanLu changed the title (WIP) Fix table keyboard navigation bugs Fix table keyboard navigation bugs Jun 9, 2021
@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

LFDanLu added 2 commits July 26, 2021 16:54
seems to work if we switch to scrollToItem from TableView instead of always using Virtualizer.scrolltoItem
@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

Copy link
Copy Markdown
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

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

lgtm in chrome/safari/ff

Copy link
Copy Markdown
Collaborator

@ktabors ktabors left a comment

Choose a reason for hiding this comment

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

LGTM
I testing it maintaining its focused key and that option/alt-tab doesn't work when the user is within the table.

@adobe-bot
Copy link
Copy Markdown

Build successful! 🎉

@LFDanLu LFDanLu merged commit b5b930f into main Jul 27, 2021
@LFDanLu LFDanLu deleted the issue_1208 branch July 27, 2021 00:39
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.

Table keyboard navigation bugs

5 participants