Skip to content
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

Focus management for SPA navs #190

Closed
domenic opened this issue Nov 18, 2021 · 8 comments
Closed

Focus management for SPA navs #190

domenic opened this issue Nov 18, 2021 · 8 comments
Labels
addition A proposed addition which could be added later without impacting the rest of the API feedback wanted

Comments

@domenic
Copy link
Collaborator

domenic commented Nov 18, 2021

This is spinning off from #25. It was also briefly mentioned in #162.

We have consistent feedback that focus management during SPA navs is an accessibility concern. This writeup goes into good detail.

I think we should make sure that by default, same-document navigations that are controlled by app history (i.e., using navigateEvent.transitionWhile()) give a good focus experience. The starting point here is to get parity with cross-document navs (MPA navs). This means:

  • For "push", "replace", and "reload" navigations, resetting focus to the <body> element, or the first autofocus="" element if there is one.
  • For "traverse" navigations, it's complicated.

https://boom-bath.glitch.me/focus-navigation-test.html is a useful test page for this.

For traverse navigations, if the page is stored in the bfcache, then the current plan per whatwg/html#5878 / whatwg/html#6696 is to restore focus. I.e., since the entire Document is kept around, we just make sure not to clear its focus state when transitioning into or out of bfcache.

But for traverse navigations where the result is not coming from bfcache, there is no attempt at focus restoration in current browsers. I.e. there is nothing like scroll restoration (cf. #187) which tries to somehow find the right element to focus in this newly-created from-network-or-cache Document. It just does the usual <body>-or-autofocus="" dance (and autofocus="" doesn't seem to work in Chrome...).

So what should we do for app history and traverse navigations?

  • On the one hand, SPA navs are supposed to be snappy and kinda like bfcache navs. So trying to make sure the focus is on the same element when you go back would be ideal.
  • On the other hand, in the general case we won't be able to identify "the same element"!
    • In most SPA architectures, off-screen content (i.e. from a previous history entry) will have been at least removed from the DOM, and probably destroyed via garbage collection; when you traverse back, the page will reconstruct new DOM elements for the content. We could keep a pointer to the element around, but that'd be a potential memory leak, and most likely that specific element won't reenter the DOM anyway, so we wouldn't get anything out of it in most cases. The only case where this would work really well would be when an SPA is implemented by just hiding and showing things, which might be the case for some toy apps but doesn't seem likely in general.
    • We could try to do some heuristic, from something simple like "if the element has an ID, use that" to something complicated like "store the fact that we are on the 3rd <input> element inside the #nearest-ancestor-with-id container and on restore try to focus based on that pointer". This seems really brittle and unpredictable.

My inclination then is to treat traverse navigations the same as push/replace/reload, and reset focus to the body or autofocus="" element by default when using app history. Anything more complicated pretty much needs to be done via manual focus() calls, IMO.

OK, so what does this all mean for the API? I think it means the following:

  • If you do navigateEvent.transitionWhile(promise, { focusReset: "after-transition" }), this waits for promise to settle and then resets focus to the <body> element or the first autofocus="" element.
    • Maybe, if the user or the developer has moved focus before the promise settles, we do nothing instead?
  • If you do navigateEvent.transitionWhile(promise, { focusReset: "manual" }), app history does nothing with focus, i.e. the probably-now-offscreen element stays focused, or if it gets removed from the DOM/made display: none then focus resets to <body> whenever that removal happens. (Note: this "focus fixup" ignores autofocus="" elements.)
  • If you do navigateEvent.transitionWhile(promise, { focusReset: "immediate" }), this immediately resets focus to the <body> element or the first autofocus="" element.

Which of these should be the default? I think "after-transition" is probably best, specifically because of the autofocus="" interaction. We want to do this focus reset whenever all the elements, including the potential autofocus target, are ready. This does lead to a potentially-problematic situation where the focus stays on an obstructed element (e.g. behind a loading spinner), but I think more likely focus would get reset to <body> due to element removal or hiding at some point, and then either stay there or get moved to the autofocus="" control once things are loaded.

Overall this proposal is OK, but it does require some manual work by developers: mostly, putting autofocus="" on appropriate places. In particular per the research cited above, screen reader users preferred resetting focus to a heading or to a wrapper element, instead of to the <body>. To achieve that, we need help from developers to tell us what element focus should go to, and that's what autofocus="" provides.

@domenic domenic added addition A proposed addition which could be added later without impacting the rest of the API feedback wanted labels Nov 18, 2021
@domenic domenic added this to the Might block v1 milestone Nov 18, 2021
@marcysutton
Copy link

Thanks for this writeup, @domenic! There are some really cool ideas here. Resetting focus to the <body> like a traditional page reload would be a much better default than doing nothing at all like the case is now a lot of the time. But we still need an API to set focus manually, as you suggest when traversing across client-rendered pages.

While autofocus="" is a useful tool, doesn't it take focus automatically when the page loads? Focus management for SPA navigation should be tied to a user interaction so that other content isn't skipped over by AT on page load (headings, landmarks, other page content, etc.) If the page is refreshed in the browser or navigated to directly via URL, focus and AT read-out should start at the top like a regular HTML page. If the user activates a nav link in a client-rendered application to a dynamic page or view, that's where moving focus becomes more advantageous. Think of re-rendering to show more detail in a web app like Gmail...it would be a bummer to have to keyboard focus all the way from the top when you expand a message.

@domenic
Copy link
Collaborator Author

domenic commented Nov 23, 2021

Hmm, I don't quite understand the distinction you're making.

Let's say I have a web app. I think I have two options for desired user experience:

  1. Going from page A to page B focuses on page B's <body>
  2. Going from page A to page B focuses on page B's <h1 tabindex="-1">.

What I'm proposing is that, whichever of these two experiences you choose, you get the same result whether your app is an MPA or a SPA:

  • To get (1) as an MPA, use <a href="page-b">, which will reset focus to <body> after the MPA nav.
  • To get (1) as a SPA, use <a href="page-b"> + app history, which will reset focus to <body> after the SPA nav.
  • To get (2) as an MPA, use <a href="page-b"> + autofocus="" on page B's <h1>, which will ensure focus gets reset to the <h1> after the MPA nav.
  • To get (2) as a SPA, use <a href="page-b"> + autofocus=""+ app history on page B's <h1>, which will ensure focus resets to the <h1> after the SPA nav.

I.e., there is complete symmetry between MPA navs and SPA navs.

You seem to be calling for some _a_symmetry, which I don't really understand. When the user loads page B, either via reload or link click or navigating directly, either the <h1> is the right starting focus target, or the <body> is... and you can choose which using autofocus="".

@WestonThayer
Copy link

I understand #190 (comment) as a distinction between first page load and subsequent navs for SPAs. When a user first visits a SPA (pasting URL or linked from an external site), they might want to start from the top of the page to familiarize themselves, maybe with the links in a navbar. But when the user clicks an internal site link, the SPA can try to offer a more efficient UX by moving focus within the page (since the user has already been through the navbar once).

Note that Gmail does not currently follow this pattern. For the inbox, both first page load and internal links move focus to the first email. But Twitter does, first page load sets focus all the way at the top of the DOM. If you TAB to a Tweet and navigate to it, then SHIFT+TAB to their back button, focus is at the top of your feed (skipping messages & left side nav).

autofocus="" would force a Gmail-style approach, but devs could easily use focusReset: "manual" to opt out.

@WestonThayer
Copy link

@domenic I agree with the end-goal you stated in https://groups.google.com/a/chromium.org/g/chromium-accessibility/c/wDUwToU8LdM:

...to be able to convert a cross-document navigation into a same-document navigation, but have the browser announce to assistive technology as if it were a cross-document navigation.

Would this be accomplished by after-transition and immediate alone? Or would whatwg/html#330 play into it? It'd be a bummer if the "load complete" notification required the fetch bit, then SPA navs where the data was already available would still have to use live regions to mimic "load complete".

@WestonThayer
Copy link

I am still worried about SPA focus restoration on back/forward navs. If focus is somewhere besides <body>, it's not too bad for the SPA to either calculate a (as you noted) brittle unique selector or put id="" on every focusable element and cache in history state. But if focus is on <body>, there's no way to cache the sequential focus navigation starting point, so parity with a bfcached MPA nav is impossible.

getSequentialFocusNavigationStartingPoint() and setSequentialFocusNavigationStartingPoint() APIs would unlock this, but knowing when to call them is a very similar problem to #187. Is there a way we could fuse the two? Maybe something like:

document.documentElement.addEventListener("beforepagerestoration", e => {
  if (!everythingIsLoadedAndClientSideRendered()) {
    e.preventDefault();
    setClientSideRenderingFinishedCallback(() => doPageRestoration(e));
  }
});

function doPageRestoration(event) {
  const { scrollPoint, sequentialFocusStartingPoint } = event.getDestination();

  if (!shouldStillDoPageRestoration(scrollPoint, sequentialFocusStartingPoint)) {
    // Don't do any restoration if something dramatic has changed,
    // e.g. if a bunch of files were deleted and we're going back to the
    // file list view. Maybe scrollLeft/scrollTop could help with this logic.
    return;
  }
  
  event.restore();
}

@domenic
Copy link
Collaborator Author

domenic commented Jan 7, 2022

Would this be accomplished by after-transition and immediate alone?

This thread, and the proposed after-transition/immediate/manual enum, is about focus management. Announcement to ATs is actually a very separate issue.

getSequentialFocusNavigationStartingPoint() and setSequentialFocusNavigationStartingPoint() APIs would unlock this, but knowing when to call them is a very similar problem to #187.

Well, in the SPA case, I think both this and #187 already have some good hooks: you'd call them during the appropriate point in the navigate event, using app history. The beforescrollrestoration event there (which, as noted in #187 (comment), doesn't really work) is more important for the MPA scenario.

@domenic
Copy link
Collaborator Author

domenic commented Jan 10, 2022

I've specced out a minimal version of this, with only "after-transition" and "manual", for now. See #197. But per #197 (comment) there are some issues with that API surface; any ideas would be welcome.

domenic added a commit that referenced this issue Feb 1, 2022
Plus, expand and organize the stuff about accessibility technology announcements and loading spinners.

Closes #25. Closes #187. Closes #190.
@domenic domenic closed this as completed in 8a3d47b Feb 1, 2022
@WestonThayer
Copy link

Got pulled into a project, just coming back to this, but just wanted to say awesome job with that PR @domenic — so excited for this work to land!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition A proposed addition which could be added later without impacting the rest of the API feedback wanted
Projects
None yet
Development

No branches or pull requests

3 participants