- Notifications
You must be signed in to change notification settings - Fork 49.9k
Description
I think we need to rethink how focus works in React. React Flare is the perfection opportunity to allow us to do this, so here are some of my thoughts. None of these ideas wouldn't be possible if it weren't for the great ideas from @sebmarkbage, @devongovett and @necolas have had. Furthermore, the discussions in #16000, #15848 and #15849 got me thinking on a better system.
Focus is a mess on the DOM, so let's not use the DOM
Focusing on the DOM is a mess today. We couple ideas around ideas around things like tabIndex and whether a specific browser treats something as focusable. This is very much a hard-coded disaster where no one really agrees on a good formula for success. Not to mention, that this just doesn't translate well for a declarative UI. How does one tab to a specific node that isn't focusable? How does one use keyboard arrows to navigate a table using keyboard arrows?
Then there's implementation. Without relying on an attribute on an element or a ref, it's very hard to say: "Hey look, let's focus to this node, given this criteria". Not to mention the performance overhead of doing this: querying or "collecting" focusable elements is an expensive O(n) task, which doesn't scale for large applications well. I noticed that wrapping the an internal large app with <FocusScope> and then collecting all focusable nodes took over 850ms on Android using Chrome. Querying the DOM nodes took even longer.
Lastly, we can't use the DOM with React Native and the story for handling focus with React Flare is important. If we instead had a React system for handling focus, then both the web and RN would be consistent and performant.
Accessible components
We already have the <Focus> and <FocusScope> event components. We could extend on React Flare and introduce a way of layering accessibility logic on to host components. In this I introduce a new API called createAccessibleComponent, but really it could be anything – ignore the naming! This is purely hypothetical discussion for now.
// input is not focusable <FocusScope> <input type="text" placeholder="Enter your username" /> </FocusScope> const FocusableInput = ReactDOM.createAccessibleComponent((props, focusable) => { return <input tabIndex={focusable ? 0 : -1} {...props} />; }); // now it's focusable <FocusScope> <FocusableInput type="text" placeholder="Enter your username" focusable={true} /> </FocusScope>If you don't use a FocusScope, then the normal DOM behaviour will continue to work as expected. FocusScope will only care about these new types of accessible component.
The focus manager should be encapsulated and relative to FocusScope
In order for focus management to be powerful, it needs to be baked into React. Event responders like FocusScope can let the manager know what scope it should be interacting with given a particular <Focus> that focuses occur in. FocusScope will also fully override the browser tabbing behaviour (like it does now) to ensure tabbing works as expected:
import { focusManager } from 'react-events/focus'; focusManager.getFocusedNode(); focusManager.getFocusedId(); focusManager.focusFirst(isRTL?: boolean = false); focusManager.focusLast(isRTL?: boolean = false); focusManager.focusPrevious(fromId?: string, isRTL?: boolean = false); focusManager.focusNext(fromId?: string, isRTL?: boolean = false, ); focusManager.focusById(id: string); const FocusableDiv = ReactDOM.createAccessibleComponent((props, focusable) => { return <div tabIndex={focusable ? 0 : -1} {...props} />; }); <FocusScope onMount={() => focusManager.focusFirst()}> <FocusableDiv focusable={true} /> <FocusableDiv focusable={true} /> <div tabIndex={0}>You can't focus this</div> </FocusScope>Focusing by focusId will propagate until an focusId is found. So this would matter for cases such:
const FocusableDiv = ReactDOM.createAccessibleComponent((props, focusable, focusId) => { return <div tabIndex={focusable ? 0 : -1} {...props} />; }); <FocusScope> <FocusableDiv focusable={true} focusId="focus-me" /> <FocusScope> <FocusableDiv focusable={true} focusId="focus-me" /> </FocusScope> </FocusScope>If focusManager.focusById('focus-me); was used on the inner FocusScope, it would focus the inner button. If used on the outer FocusScope, it would focus the outer button. If the outer FocusScope didn't have an id that matched, then it would propagate the lookup to the inner FocusScope.
Doing this, it makes it possible to apply keyboard navigation:
function handleKeyPress(key) { if (isValidArrowKey(key)) { const currentId = focusManager.getFocusedId(); const nextId = findNextId(currentId, key); focusManager.focusById(nextId); } } <FocusScope onKeyPress={handleKeyPress}> <FocousableCell focusable={true} focusId="AA" /> <FocousableCell focusable={true} focusId="AB" /> <FocousableCell focusable={true} focusId="AC" /> <FocousableCell focusable={true} focusId="BA" /> <FocousableCell focusable={true} focusId="BB" /> <FocousableCell focusable={true} focusId="BC" /> <FocousableCell focusable={true} focusId="CA" /> <FocousableCell focusable={true} focusId="CB" /> <FocousableCell focusable={true} focusId="CC" /> </FocusScope>Furthermore, <FocusScope>s can also have focusIds that allows you to move focus to a specific scope. That particular event component can then act upon receiving focus <FocusScope onFocus={...}>.
It can simplify <Focus>
<Focus onFocus={...}> <div> <FocusableDiv focusable={true} /> </div> </Focus>Before, focus would only be of the direct child of the <Focus> component. This made it somewhat problematic when you wanted to find the focusable element that was not a direct child. Focus no longer needs to be coupled with "bubbling up" through the DOM, but rather it bubbles from accessible component to event components. So doing this, will still result in the nearest focusable child being passed to the Focus:
<Focus onFocus={...}> <div> <FocusableDiv focusable={true}> <FocusableDiv focusable={true} /> </FocusableDiv> </div> </Focus>This can be fast too
In terms of performance, we can actually fast-path how this all works because we're no longer using the DOM, but event components within the Flare event system. We'd have optimized data collections that ensure that the least possible time is taken traversing focusable elements by leveraging a separate internal data structure that is separate from the Fiber and DOM structures. The cost is that this will take additional memory and time to construct when a focus scope gets mounted/unmounted. Given this shouldn't be a rapid event, it's worth the trade-off.
Also, given we're not wrapping FocusScope with a context provider (as mentioned in the FocusManager PR), which should get improved performance from not needing to do context lookups and traversals.
Focus and FocusScope, focusManager
Given that they now share underlying implementation details, they all should come from the same module. So going forward, it makes sense to import them all form react-events/focus.
The nice benefit from this is that this actually fixes a bunch of issues with the current implementation, where we can't use FocusScope as a hooked event component. With the changes outlined in this issue, it should allow for them to be used via the useEvent hook.
We can build in great dev tooling around the focus system
We can build in great support for debugging in React Dev Tools when working with focus and
this will help improve accessibility within apps that use <Focus>, <FocusScope> and focusManager. Plus it would support any future APIs that add accessibility benefits to components.