Last active
March 14, 2022 21:06
-
-
Save WestonThayer/c8e64a64c278154efb1975d87b68b3fd to your computer and use it in GitHub Desktop.
Basic Next.js focus restoration
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Browsers keep track of where your keyboard focus is when you click | |
// a link, and they restore focus to the same link when you navigate | |
// back (via https://web.dev/bfcache/) | |
// | |
// SPAs like Next.js break this paradigm because they handle routing | |
// themselves. This is a basic approach bringing the feature back. It | |
// doesn't handle restoring the view-state (well, Next.js handles | |
// scroll restoration out-of-the-box, but that's it) like re-opening | |
// dropdowns or dialogs or FAQ items that contained the clicked link. | |
import * as React from "react"; | |
import Router from "next/router"; | |
import finder from "@medv/finder"; | |
const MyApp = (props) => { | |
React.useEffect(() => { | |
let previousActiveElementSelector = ""; | |
let isGoingBack = false; | |
const handleRouteBack = () => { | |
isGoingBack = true; | |
return true; // Let Next.js continue to route | |
}; | |
const handleRouteChangeStart = () => { | |
const activeElement = window.document.activeElement; | |
// Only cache the currently focused element if going forward | |
if (!isGoingBack && activeElement) { | |
// Can't simply cache document.activeElement because the | |
// Next.js Page containing it is about to be unmounted. | |
// After that, calling .focus() won't do anything, the | |
// element is no longer in the DOM. | |
// | |
// So instead, cache a unique CSS selector we can use to | |
// find it when the Page is remounted. @medv/finder is | |
// a fast little helper to calculate unique selectors. | |
// Hopefully doesn't become a performance bottleneck. | |
previousActiveElementSelector = finder(activeElement); | |
} | |
}; | |
const handleRouteChangeComplete = () => { | |
// HACK: this event fires before React has rendered the | |
// new page to the DOM. So delay just a bit, there's | |
// gotta be a better hook though? | |
window.setTimeout(() => { | |
if (isGoingBack) { | |
// Restore focus to cached element | |
if (previousActiveElementSelector) { | |
const el = window.document.querySelector( | |
previousActiveElementSelector, | |
); | |
if (el && el.focus) { | |
el.focus(); | |
} | |
} | |
} else { | |
// Going forward, maybe update a live-region or | |
// focus a heading? | |
} | |
isGoingBack = false; | |
}, 10); | |
}; | |
// https://nextjs.org/docs/api-reference/next/router#routerbeforepopstate | |
Router.beforePopState(handleRouteBack); | |
// https://nextjs.org/docs/api-reference/next/router#routerevents | |
Router.events.on("routeChangeStart", handleRouteChangeStart); | |
Router.events.on("routeChangeComplete", handleRouteChangeComplete); | |
return () => { | |
Router.events.off("routeChangeStart", handleRouteChangeStart); | |
Router.events.off("routeChangeComplete", handleRouteChangeComplete) | |
}; | |
}); | |
return ...; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment