Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Created December 20, 2019 23:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ryanflorence/907784b35d2fdc47d5b325c408aa5b15 to your computer and use it in GitHub Desktop.
Save ryanflorence/907784b35d2fdc47d5b325c408aa5b15 to your computer and use it in GitHub Desktop.
export function useBodyFocusOnNav() {
const { location } = useLocation();
// TODO: figure out why only "root links" seem to reset the tab order. Right
// now, in the demo app, the global links are blurred on navigation, but the
// nested links are not (?) The focus does move to the body when I "watch
// expression" in the dev tools, but the tabbing doesn't seem to be affected.
// maybe could try to find data-reach-skip-nav and focus that if it's there?
useEffect(() => {
document.activeElement.blur();
document.body.focus();
}, [location]);
}
// Focus Management
//
// Cases:
//
// 1. Navigate to sibling:
// [a] => [b*] : focus b
// [a, x, y] => [b*] : focus b
//
// 2. Navigate to child
// [a] => [a, b*] : focus b
//
// 3. Navigate to grandchild
// [a] => [a, b*, c] : focus b
//
// 4. Navigate up to parent
// [a, b] => [a*] : focus a
//
// 5. Navigate up from grandchild
// [a, b, c] => [a*] : focus a
//
// 6. Navigate to self
// [a] => [a*] : focus a
//
// 7. Navigate to self with an index child
// [a, index] => [a*, index] : focus a
//
// 8. Navigate from sibling to index route
// [a, b] => [a*, index] : focus a
// TODO think a little more about this case, generally I think we should focus
// the "changed content", and in this case that would be the index, but I chose
// to focus the parent because it'd be weird to click the same link and get
// different behavior (first click would focus "a", a later click might focus
// "index" depending on where you are).
// We only want to focus once, so we use requestAnimationFrame as a debounce
// mechanism. This stores the frame that we can cancel as the useEffects fire
// up the element tree.
let focusFrame = null;
// We don't want to focus anything on the first render of an app.
let initialRender = true;
// Basic strategy is to keep track of the focus targets, then after a location
// change, we can compare the new targets to the old ones to determine which is
// the "top-most changed target" and focus it.
let prevTargets = [];
let newTargets = null;
function findTopmostChangedTarget() {
// Special case, we can bail early if we only have one item. This is both an
// optimization and makes it easy to allow for navigating to the root index
// route since it has no route above it so our "length - 2" later in the code
// would break.
if (newTargets.length === 1) {
return newTargets[0].node;
}
// handle cases 1, 2, and 3
for (let i = 0, l = newTargets.length; i < l; i++) {
if (
// prevTargets is shorter than new targets (we navigated down)
prevTargets[i] == null ||
// found the top most changed route
prevTargets[i].node !== newTargets[i].node
) {
// allow for case 8
if (newTargets[i].isIndex) {
break;
} else {
return newTargets[i].node;
}
}
}
const last = newTargets[newTargets.length - 1];
// handle cases 7 and 8
if (last.isIndex) {
// the parent of the index
return newTargets[newTargets.length - 2].node;
} else {
// handle cases 4, 5, and 6
return last.node;
}
}
function focus() {
if (initialRender) {
initialRender = false;
} else {
findTopmostChangedTarget().focus();
}
prevTargets = newTargets;
newTargets = null;
}
function scheduleFocus(target) {
if (newTargets === null) newTargets = [];
newTargets.unshift(target);
cancelAnimationFrame(focusFrame);
focusFrame = requestAnimationFrame(focus);
}
export function useNavFocus({ ref: appRef } = {}) {
const { location, match } = useContext(RouterContext);
const { isIndex } = match.route;
const ref = useRef();
const props = {
tabIndex: "-1",
ref: appRef || ref,
style: { outline: "none" }
};
useEffect(() => {
// TODO: make sure suspended inner Router's don't steal focus, probably
// want to send `location` in here, keep it in a module cache and then
// ignore the effects in the suspended router when they finally fire
scheduleFocus({ node: ref.current, isIndex });
}, [location, isIndex]);
return props;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment