[react-aria] FocusScope with contain prevents programmatic scrolling when `prefers-reduced-motion: reduced` and `scroll-behaviour: auto` is given
Provide a general summary of the issue here
Consider the following code example
<FocusScope contain>
<ScrollableContainer>
<p id="test">Hello, world!</p>
{/** Lots of text */}
<a href="#test">Scroll back to top</a>
</ScrollableContainer>
</FocusScope>
The issue seems to only occur when contain is set to true on the FocusScope
๐ค Expected Behavior?
I'd expect that when I click on a hash anchor link, it should be consistent with the default browser behaviour, scrolling to the targeted element.
๐ฏ Current Behavior
If I have my acessibility settings to have prefers-reduced-motion set to reduce, with the scroll behaviour of the given container being auto then clicking on the "Scroll back to top" link does nothing.
๐ Possible Solution
Adding an onclick event for the link similar to the following can resolve the issue.
(event) => {
const href = e.currentTarget.href;
if (!href.startsWith('#')) return;
document.querySelector(href)?.scrollIntoView();
}
Though this isn't a reasonably scaleable solution.
๐ฆ Context
No response
๐ฅ๏ธ Steps to Reproduce
Using react-aria
export default function App() {
return (
<>
<FocusScope contain>
<div style={{ height: 40, overflow: 'auto' }}>
<p id="wont">This won't be scrolled to</p>
<p>Lorem ipsum</p>
<a href="#wont">Scroll back to top</a>
</div>
</FocusScope>
<FocusScope contain={false}>
<div style={{ height: 40, overflow: 'auto' }}>
<p id="will">This will be scrolled to</p>
<p>Lorem ipsum</p>
<a href="#will">Scroll back to top</a>
</div>
</FocusScope>
</>
);
}
Version
3.34.1
What browsers are you seeing the problem on?
Chrome
If other, please specify.
No response
What operating system are you using?
MacOS
๐งข Your Company/Team
No response
๐ท Tracking Issue
No response
Looks like the anchor link gets focus again after clicking, because focus scope containment means the focus can't be lost to the body afterwards and is instead restored to the last focused element in the scope. When it gets focus again, it's scrolled into view, resulting in it appearing like the scroll to top has not happened.
We can't allow the focus to be lost to the body because that would break containment, but we also can't focus the header or anything else in that example, there is nothing to focus except the link.
I would instead include another link, it can probably be hidden if you need, and send focus to that when the anchor link is clicked. This will give a place for focus to go, won't break containment, and should be better for ScreenReaders in general where the cursor follows focus.
Adding a hidden link it's very scaleable if I need to manually add that for every case I have a href within FocusScope, let alone even remembering/realising I need to do that.
As a workaround, I have the following which is at least reliable in Chrome/Safari (since Safari seems the most janky when trying to work around this)
const scrollToHashHref = (event: React.MouseEvent<HTMLAnchorElement>) => {
const href = event.currentTarget.getAttribute('href')
if (!href || !href.startsWith('#')) return
// Href targets an id, so let's try scroll to that element
const ref = document.querySelector(href)
if (!(ref instanceof HTMLElement)) return
// We need to ensure the element is focusable, focus on it, and then restore it's original focusability
// If we don't do this...
// - FocusScope will refocus on the link, preventing scroll
// - FocusScope will prevent using `ref.focus()` on non-focusable elements in Safari
const initialTabIndex = ref.getAttribute('tabindex')
ref.setAttribute('tabindex', '-1')
ref.focus()
if (initialTabIndex == null) {
ref.removeAttribute('tabindex')
} else {
ref.setAttribute('tabIndex', initialTabIndex)
}
}
Though I don't like the potential a11y implications of this, especially when I'm using react-aria to improve a11y for our users.
Is there no way react-aria could be updated to listen to these events or something similar to fix this scrolling behaviour for us?
Maybe it'd be better to back up and see what the problem we're actually trying to solve is?
I would expect a containing FocusScope to only be used really in a Dialog (which already provides one) and the dialog itself is actually focusable, so should prevent this situation of needing to remember. It should just always work.
So what is the component you're actually trying to build here? Let's start with that.
Sure, there's a few component patterns this is affecting for me (a dialog/modal and pop-out aside). Here's an example (in this case it's trying to scroll to the heading at the top of the modal, but we do have cases where we need to scroll somewhere in the middle.)
https://github.com/user-attachments/assets/3c05c148-3f64-4709-94df-f6c8841f2b4e
This is how it looks without using any workarounds I've mentioned earlier.
We're also not using the Modal/Dialog component provided by react-aria, but I can look into whether this issue exists with those too
edit: I've played around with Modal by editing the html in dev tools and the same issue remains
https://github.com/user-attachments/assets/b5b5622c-ba91-4784-9477-5d346adcf0da
I tried using our modals, it would appear that the issues still exists https://codesandbox.io/p/sandbox/kc4t7v even without the auto scroll behavior and the prefers reduced motion. It looks like it still won't "lose focus to the Dialog body" because the last focused thing in the dialog was the link, so it places focus back there when it's lost.
Will see if anyone on the team has ideas on how to address this.