A11y: OverviewEngine Focus Handling

Hi,

I am working on accessibility fixes in an A12 application and I am currently stuck on how I could implement my requirements with the OverviewEngine.

So we have an OverviewEngine in a mobile application. Due to limited screen size, the form triggered by clicking an element in the overview does not get displayed next to the overview, but instead of it, meaning that the overview gets removed from the dom.

Accessibility requirements mandate that upon returning to the overview from a specific element, the triggering element in the overview must be focused. This logic seems to be working in the focus handling example in the dev-app: <INTERNAL_LINK>

Unfortunately, the example in the dev-app uses a mocked OverviewEngine and thus does not provide any solutions to an engine-specific problem I am struggling with:

Our OverviewEngine is set to infinite scrolling. Assume I scroll down by more than one screen-height and click on an item. After returning to the overview, the scrolling position is back at the top, meaning that the triggering element is now outside of the viewport.

In this state, if the row of the element I want to focus is far enough outside of the viewport, the corresponding react component is not rendered and thus the element cannot be focused

Is there a way to solve this problem with the overview engine, for example:

  • making it remember its scrolling position,
  • manually triggering a scroll to the element that should receive focus,
  • forcing it to render all elements that have loaded data, also outside of the viewport?

Thanks in advance to anyone who has an idea on how I could approach this :innocent:

Additional Info: The uiState.scrolling property in the store seems to survive the re-enter (see screenshot). Might there be a way to trigger the OverviewEngine to actually go to that place?

hi @sebastian-windy-coast,

Sorry for a late reply. I hope that you found a solution for this already, but if not, I can suggest you one solution that I can come up with :grin:


The problem: In single-pane (mobile) layout, opening a detail form
unmounts the overview. When you come back, the list has jumped to the top and
keyboard focus is on <body> — so keyboard and screen-reader users lose their
place.

The good news: You can fix both the scroll position and the row focus
without changing any engine or widget source — just a custom
InfiniteScrollTableBody (via the component map) plus a tiny redux middleware.
Here’s the shape of the solution.


1. Restore the scroll position

Provide your own InfiniteScrollTableBody through the overview’s component map
that wraps the engine’s original one and patches its infiniteScrollOptions.

The engine’s scrolling ui-state slice (visibleStart, pageSize,
pageNumbers) survives the unmount — so on remount you can read it back and
do two things:

  • Intercept the first loadData call. On remount the InfiniteLoader asks
    for page 1 (startIndex = 0). Rewrite just that first call’s range to the
    pages the user was actually on:
    startIndex = min(pageNumbers) * pageSize,
    stopIndex = (max(pageNumbers) + 1) * pageSize - 1.
    Let every later call pass through untouched.
  • Set scrollToIndex. Add scrollToIndex: visibleStart and
    scrollToAlignment: "start" to overrideListProps, so react-virtualized
    scrolls that row back to the top once the data arrives.

That reloads the right rows and scrolls back to them.

2. Restore focus on the clicked row

Scroll restore gets you to the right place; focus restore puts the ring back
on the right row. The catch: on remount nothing in engine state remembers
which row was clicked — the selection slice is cleared on unmount. So capture
the id before the unmount:

  • A small redux middleware matches ActivityActions.push (which fires while
    the overview is still mounted). The pushed child activity carries
    descriptor.instance (the clicked row’s document id) and
    initiatingActivityId (the parent overview). Stash
    { activityId → documentId } in a module-level Map (not redux — it must
    outlive the unmount but must not survive a logout).
  • On remount, your custom table body reads that id, finds it in the engine’s
    data array (findIndex by id → absolute row index), and once that page is
    loaded, focuses the row.

Matching by document id (not index) means a row added or removed in the
meantime won’t send focus to the wrong place; if the row is gone entirely, focus
just gives up and scroll restore still works.

3. Actually focusing the DOM row

react-virtualized only renders rows near the scroll position, so the target may
not be in the DOM yet. The focus helper:

  1. Looks for the row now; if present, .focus() and done.
  2. Otherwise scrolls it into the virtualized window
    (scrollTop = index * rowHeight) and waits with a MutationObserver
    (with a ~5 s safety timeout), re-checking as rows mount.
  3. After focusing, holds focus for ~800 ms (re-asserting on focusout) to
    survive a React re-render that would otherwise blur it.

The row’s absolute index is recoverable from its own layout:
round(parseInt(style.top) / parseInt(style.height)).

Hi @hieu-loud-meadow,

Thank you so much for your reply! Especially the restoring of the scroll position seems much more elegant in your solution than the workaround I found back then.

We ended up not pursuing the fix further back then, but if we do and I find out something new, I’ll comment it here.