Focus Management? #9555
Replies: 6 comments 2 replies
-
First off, thanks for all the work you've put into React Router. Please forgive my ignorance if my question sounds silly, I must lack context, or understanding.
Why would the focus be on Specially talking about how usually applications construct the screen layouts, going from |
Beta Was this translation helpful? Give feedback.
-
Related: #9863 |
Beta Was this translation helpful? Give feedback.
-
Thanks @ryanflorence, this is great. I have some thoughts on this narrow point:
Years ago, I set about solving this tension myself with some limited success. The first thing to point out is that User agent support may or may not match what react-router aims to support: What I ended up doing was this (not my finest hour, but may be of use): /**
* Focuses an element, setting `tabindex="-1"` if necessary.
*
* @param target the element to focus.
* @param preventScroll true if the browser should not scroll the target element into view, false otherwise.
*/
export const focusElement = async (
element: HTMLElement | SVGElement,
preventScroll = false,
): Promise<boolean> => {
// See: https://developer.paciellogroup.com/blog/2014/08/using-the-tabindex-attribute/
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#Browser_compatibility
// See: https://github.com/whatwg/html/issues/834
// See: https://stackoverflow.com/questions/4963053/focus-to-input-without-scrolling/6610501
try {
// Set tabindex="-1" if necessary.
// TODO avoid setting tabindex when we're confident we don't need to?
if (!element.hasAttribute("tabindex")) {
element.setAttribute("tabindex", "-1");
// We remove tabindex after blur to avoid weird browser behavior
// where a mouse click can activate elements with tabindex="-1".
const blurListener = (): void => {
element.removeAttribute("tabindex");
element.removeEventListener("blur", blurListener);
};
element.addEventListener("blur", blurListener);
}
if (preventScroll) {
// preventScroll has poor browser support, so we restore scroll manually after setting focus.
// see https://caniuse.com/#feat=mdn-api_htmlelement_focus_preventscroll_option
// TODO detect if browser supports preventScroll and avoid `withRestoreScrollPosition`
// shenanigans if so.
withRestoreScrollPosition(() => {
try {
element.focus({ preventScroll: true });
} catch {
// If focus() with options throws, fall back on calling focus() without any arguments.
element.focus();
}
});
} else {
// Avoid passing anything to focus() (when we can) to maximize browser compatibility.
element.focus();
}
return document.activeElement === element;
} catch (e: unknown) {
// Apparently trying to focus a disabled element in IE can throw.
// See https://stackoverflow.com/a/1600194/2476884
console.error(e);
return false;
}
};
/**
* Scrolls the window to the given scroll position.
*
* For smooth scrolling behavior you might want to use the smoothscroll
* polyfill http://iamdustan.com/smoothscroll/
*
* If the user has indicated that they prefer reduced motion, the smoothScroll value will be ignored.
*
* @param scrollPosition the scroll position to scroll to
* @param smoothScroll true for smooth scrolling, false otherwise
*/
export const setScrollPosition = (
scrollPosition: ScrollPosition,
smoothScroll = false,
): void => {
if (!smoothScroll || prefersReducedMotion()) {
// Use old form of scrollTo() (when we can) to maximize browser compatibility.
window.scrollTo(scrollPosition.x, scrollPosition.y);
} else {
try {
window.scrollTo({
behavior: "smooth",
left: scrollPosition.x,
top: scrollPosition.y,
});
} catch {
// If scrollTo with options throws, fall back on old form.
// See https://github.com/Fyrd/caniuse/issues/1760
// See https://github.com/frontarm/navi/issues/71
// See https://github.com/frontarm/navi/pull/84/files
window.scrollTo(scrollPosition.x, scrollPosition.y);
}
}
};
const getScrollPositionRestorer = (): (() => void) => {
const scrollPosition = getScrollPosition();
return () => {
setScrollPosition(scrollPosition);
};
};
/**
* Executes a function that may (undesirably) change the window's scroll position
* and then restores the window scroll position and scroll behavior.
* @param funcWithScrollSideEffect a function to execute that may (undesirably) change the window's scroll position
*/
const withRestoreScrollPosition = <T>(funcWithScrollSideEffect: () => T): T => {
const restoreScrollPosition = getScrollPositionRestorer();
const result = funcWithScrollSideEffect();
restoreScrollPosition();
return result;
}; This has "worked" for me for years and I haven't revisited it since. Maybe Hope this is useful. On this point:
The best work available is probably this (which I'm sure you're across, but other participants here might not be): https://www.gatsbyjs.org/blog/2019-07-11-user-testing-accessible-client-routing/ The tentative conclusion there is to focus a skip link, which somehow never sat right with me... On this:
That's exactly the conclusion I came to when I did this: https://github.com/oaf-project/oaf-react-router#reset-scroll-and-focus-after-push-and-replace-navigation. (Don't pay that too much attention, it's a relic from the react-router v4/v5 days and I'm only now bashing it into shape for v6 (if indeed it is even still relevant/necessary, which it may not be). |
Beta Was this translation helpful? Give feedback.
-
Hey folks, a colleague made me aware of this thread and I wanted to give a quick summary of where we landed with the navigation API and accessibility/focus management. As @ryanflorence mentions in the OP, the goal is that you tell the browser when your SPA navigation starts/finishes, and at the end, the browser "does all the stuff it normally does for MPA navigations". What does that mean in practice, for the questions discussed here?
(Other things that are relevant to the "SPA-like-MPA" behavior, but not as a11y focused, are scroll to fragments and scroll resetting, scroll position restoration, and loading spinners and stop buttons.) Anyway, I hope this is helpful. Maybe React Router can use the navigation API, where available, under the hood in order to get truly-native a11y announcements? Similarly, maybe it'd be interesting to align the default focus-reset behavior with the navigation API? Any feedback is welcome; our fondest wish is to have provided good primitives for libraries like yours to build on. |
Beta Was this translation helpful? Give feedback.
-
Any new update? |
Beta Was this translation helpful? Give feedback.
-
It's confusing as to what screen readers announce document.title change. I am not getting an announcement with Chrome + VoiceOver.
Are you suggesting |
Beta Was this translation helpful? Give feedback.
-
Overview
One challenge with client side routing is that screen reader users click a link and have no idea anything happened. For keyboard users (screenreader or not) it's then a bit of pain to tab through everything to get to the changed layout.
Managing focus when the app navigates around would be fantastic for these users.
Before 6.4, React Router wasn't able to help here because data loading and focus management on route transitions are coupled. We tried in
@reach/router
to manage focus automatically, but typically all that was focused was a div with a spinner in it, which isn't actually useful. We needed to know about data.Now that we know about data, we can wait until it has loaded and then manage focus.
Scope
Aside from aria-announcements on route changes, we're kind of blazing the trail here. There's limited research and pretty much no prior art (except our own work in
@reach/router
) to know what the right thing to do is.The point is that this decision is not one that we, as the framework, can answer for an application. I also suspect that it varies from one app to another--and even one route to another in the same app.
With our current understanding, we can't generalize what to focus, but we can help with when.
API
Behavior
With nested routes, we can know the "topmost changed route" and focus the ref passed to the closest
useRouteFocus
to it. Just likeerrorElement
, it would bubble.For instance, if you had three nested routes:
/
to/products
would focus the ref inside of<Products>
/products
to/products/123
would focus the ref inside of<ProductDetailPage/>
/
to/products/123
would focus the ref inside<Products>
, not<ProductDetailPage>
because the "topmost changed route" is/products
.There are more nuances that we learned in
@reach/router
(like index routes) but there is one big question we need to answer before spending more time specifying this feature...Conflicts with Scroll Restoration
This is a big problem that I don't know how to solve and why I've opened this discussion. Without a resolution here this effort is blocked.
Focus management doesn't exist in a vacuum. Another accessibility concern is scroll restoration. When users move through the history stack, React Router restores the scroll position for that location.
If React Router now also moves focus to a new element, the browser will move the scroll position to scroll that element into view 🫢
In other words: focus management and scroll restoration are, in most cases, mutually exclusive 😩
I have no idea what to do about this.
I'm not sure.
(6) Recommend a UI design
I can put on my designer hat and imagine a UI that ensures every element focused on navigation is positioned independent of the window scroll. Headers and child route nav bars are commonly positioned with
sticky
or are otherwise independent of scroll position (sticky headers, persistent sidebars, etc.) In a design like this, focus and scroll restoration won't conflict.It seems to me that's the only way I'd ever recommend using this feature. Everything else breaks too many user's experience with our apps.
(5) Just recommend aria announcements
It's easy to get idealistic and think that focus management is the most important thing and that the app isn't accessible without it.
I have done a ton of accessibility work in my career and consider myself a web accessibility advocate. I explored this in the first place with
@reach/router
. I built Reach UI. I've led accessibility efforts at my job. I've led accessibility workshops. I wove accessibility concepts throughout my React workshops. I've been through multiple accessibility audits and attended training from Web AIM. Heck, I spent a month turning off my monitor and using a screen reader for an hour every morning to learn how to use the dang thing. If I can't be considered an accessibility advocate, it's hard to imagine who can be 😅I say all of this to help temper people's passion (that I share!) for accessibility, cause I'm about to try to justify punting on this 😆 so please hear me out.
What if we just don't do anything about focus and stick to recommending navigation announcements with an aria-live region?
What does it actually mean for the end user:
<nav>
, so it's not hard to jump to the next significant region landmark near itConsider traditional document navigation without JavaScript. Focus is not managed there either. This is exactly how they would get to where they want to be when navigating in that scenario too. Perhaps announcements are the only thing we need.
Additionally, we met with the Chrome team about the Navigation API a couple years ago and gave them one simple piece of feedback as the maintainers of the most popular client side router in the world: just let us tell the browser on navigation events that we've got some async work to do, and then tell it when we're done, and then just do the things browsers do with normal document navigation along the way. Stuff like spin the favicon, tell the screenreader about the new document, etc.
And indeed, that made it into the spec. Consider this potential future code:
And from the Chrome spec
There is a future where CSR does what it always should have done in the first place: what the browser does for document navigations.
What do you all think?
Beta Was this translation helpful? Give feedback.
All reactions