WICG / virtual-scroller

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Support moving focus to all items using tab key

jeonghee27 opened this issue · comments

Hello. I am interested in this for applying to TV framework.

In case each item has tabindex, I cannot traverse all items with tab button.
Only the rendered items can have focus by tab.

Hi @jeonghee27, items are rendered progressively as the user scrolls, and tabbing through items normally causes scrolling in order to display the item e.g. see https://jsbin.com/zasotuvozu/1/edit?html,output

The tricky part is handling the edge cases, e.g. while the focus is on the first focusable, user presses Shift + Tab in order to jump to the last focusable item (similarly, while focus in on last focusable and user presses Tab to jump to the first focusable).

As of now, you'd have to listen for Tab keydown event, figure out if focus is on first/last focusable, and scroll (scrollToIndex method) to the last/first element then focus that, e.g.

scroller.updateElement = (element, item, index) => {
  element.textContent = index + ' - ' + item.name;
  element.tabIndex = 0;
  element.setAttribute('itemindex', index);
};
  
const items = [];
for (let i = 0; i < 100; i++) {
  items.push({name: 'item-' + i});
}
scroller.itemSource = items;

scroller.addEventListener('keydown', event => {
  // Not a Tab, bail out.
  if (event.key !== 'Tab') return;
  
  const focusedElem = document.activeElement;
  // Not a scroller child, bail out.
  if (focusedElem.parentNode !== scroller) return;

  const itemindex = Number(focusedElem.getAttribute('itemindex'));
  let newItemindex = -1;
  // Tab, last element focused ==> jump to first!
  if (!event.shiftKey && itemindex === items.length - 1) {
    newItemindex = 0;
  }
  // Shift + Tab, first element focused ==> jump to last!
  if (event.shiftKey && itemindex === 0) {
    newItemindex = items.length - 1;
  }
  if (newItemindex >= 0) {
    event.preventDefault();

    // After next scroll...
    scroller.addEventListener('scroll', () => {
      // Wait for rendering...
      requestAnimationFrame(() => {
        // And finally, focus!
        const elemToFocus = scroller.querySelector(`[itemindex="${newItemindex}"]`);
        elemToFocus.focus();
      });
    }, {once: true});
    // Ok, let's scroll!
    scroller.scrollToIndex(newItemindex);
  }
});

Here a running example: https://jsbin.com/doceqezodu/1/edit?html,output
Note that I'm using the fix-lit-html branch in there, as it has a fix to ensure the dom order is kept (#141) - very important for tab order!

This solution is imperfect tho, because we're trapping the focus within the scroller - e.g. user cannot reach the url bar of the browser.

If you could ALWAYS render first & last focusables, then we could leverage the native browser scrolling behavior.

@domenic @bicknellr WDYT? Maybe introduce a 4th param in the ItemSource constructor, something like alwaysRender(index) => Boolean method? It would be a general purpose solution for always rendering arbitrary items. One risky side effect would be that ALL items can be rendered by setting alwaysRender() => true...

This seems like something that will be fixed by searchable invisible DOM almost automatically. /Cc @rakina

Thank you for the kind and detailed reply.
I checked that the problem I found has been resolved in the "fix-lit-html" branch. And I saw it merged to master. master also works well.
If I need focus looping, I will follow your guide.
I will check again once it will be changed to "searchable invisible DOM"
I think this issue can be closed.