diff --git a/packages/react-router-dom/.size-snapshot.json b/packages/react-router-dom/.size-snapshot.json
index f350e4a180..c2861f8018 100644
--- a/packages/react-router-dom/.size-snapshot.json
+++ b/packages/react-router-dom/.size-snapshot.json
@@ -1,26 +1,26 @@
{
"esm/react-router-dom.js": {
- "bundled": 7978,
- "minified": 4880,
- "gzipped": 1618,
+ "bundled": 10547,
+ "minified": 6376,
+ "gzipped": 2040,
"treeshaked": {
"rollup": {
- "code": 1250,
- "import_statements": 417
+ "code": 1285,
+ "import_statements": 440
},
"webpack": {
- "code": 3322
+ "code": 3336
}
}
},
"umd/react-router-dom.js": {
- "bundled": 159709,
- "minified": 57597,
- "gzipped": 16540
+ "bundled": 162216,
+ "minified": 58840,
+ "gzipped": 16893
},
"umd/react-router-dom.min.js": {
- "bundled": 97476,
- "minified": 34651,
- "gzipped": 10216
+ "bundled": 99449,
+ "minified": 35481,
+ "gzipped": 10400
}
}
diff --git a/packages/react-router-dom/docs/api/Focus.md b/packages/react-router-dom/docs/api/Focus.md
new file mode 100644
index 0000000000..c7504d5c82
--- /dev/null
+++ b/packages/react-router-dom/docs/api/Focus.md
@@ -0,0 +1,69 @@
+# <Focus>
+
+Provides a way for an application to add [focus management](https://developers.google.com/web/fundamentals/accessibility/focus/using-tabindex#managing_focus_at_the_page_level) after navigation for better accessibility.
+
+```jsx
+import { Focus } from 'react-router-dom'
+
+
+ {ref => (
+
+ {/* ... */}
+
+ )}
+
+```
+
+`Focus` uses a render prop to provide a `ref`. The `ref` should be passed to the element that will be focused.
+
+In order for `Focus` to work, the component type for the focused element needs to either be natively focusable (like an `` or a `
+ ));
+ const About = React.forwardRef((_, ref) => (
+
+
About
+
+ ));
+
+ const history = createMemoryHistory();
+ renderStrict(
+
+
+ {ref => (
+
+ }
+ />
+ }
+ />
+
+ )}
+
+ ,
+ node
+ );
+
+ jest.runAllTimers();
+
+ const homeDiv = node.querySelector("#home");
+ expect(document.activeElement).toBe(homeDiv);
+
+ history.push("/about");
+
+ jest.runAllTimers();
+
+ const aboutDiv = node.querySelector("#about");
+ expect(document.activeElement).toBe(aboutDiv);
+ });
+
+ it("warns if ref isn't attached to an element (body focused)", () => {
+ jest.spyOn(console, "warn").mockImplementation(() => {});
+
+ const Home = ({ innerRef }) => (
+
+
Home
+
+ );
+
+ const About = () => (
+
+
About
+
+ );
+
+ const history = createMemoryHistory();
+ renderStrict(
+
+
+ {ref => (
+
+ }
+ />
+ } />
+
+ )}
+
+ ,
+ node
+ );
+
+ jest.runAllTimers();
+
+ const homeDiv = node.querySelector("#home");
+ expect(document.activeElement).toBe(homeDiv);
+ expect(console.warn).toHaveBeenCalledTimes(0);
+
+ history.push("/about");
+
+ jest.runAllTimers();
+
+ expect(document.activeElement).toBe(document.body);
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ "There is no element to focus. Did you forget to add the ref to an element?"
+ );
+ });
+ });
+ });
+
+ describe("preserve", () => {
+ describe("false (default)", () => {
+ it("re-focuses for new location re-renders", () => {
+ const history = createMemoryHistory();
+ renderStrict(
+
+
+ {ref => (
+
+
+
+ )}
+
+ ,
+ node
+ );
+
+ jest.runAllTimers();
+
+ const input = node.querySelector("input");
+ const wrapper = input.parentElement;
+ const initialFocused = document.activeElement;
+
+ expect(wrapper).toBe(initialFocused);
+
+ // steal the focus
+ input.focus();
+ const stolenFocus = document.activeElement;
+ expect(input).toBe(stolenFocus);
+
+ // navigate and verify wrapper is re-focused
+ history.push("/somewhere-else");
+
+ jest.runAllTimers();
+
+ const postNavFocus = document.activeElement;
+
+ expect(wrapper).toBe(postNavFocus);
+ });
+ });
+
+ describe("true", () => {
+ it("does not focus ref if something is already ", () => {
+ const history = createMemoryHistory();
+ renderStrict(
+
+
+ {ref => (
+
+
+
+ )}
+
+ ,
+ node
+ );
+
+ jest.runAllTimers();
+
+ const input = node.querySelector("input");
+ const wrapper = input.parentElement;
+ const initialFocused = document.activeElement;
+
+ expect(wrapper).toBe(initialFocused);
+
+ // steal the focus
+ input.focus();
+ const stolenFocus = document.activeElement;
+ expect(input).toBe(stolenFocus);
+
+ // navigate and verify wrapper is NOT re-focused
+ history.push("/somewhere-else");
+
+ jest.runAllTimers();
+
+ const postNavFocus = document.activeElement;
+
+ expect(postNavFocus).toBe(input);
+ });
+ });
+ });
+
+ describe("preventScroll", () => {
+ const realFocus = HTMLElement.prototype.focus;
+ let fakeFocus;
+
+ beforeEach(() => {
+ fakeFocus = HTMLElement.prototype.focus = jest.fn();
+ });
+
+ afterEach(() => {
+ fakeFocus.mockReset();
+ HTMLElement.prototype.focus = realFocus;
+ });
+
+ it("calls focus({ preventScroll: false }} when not provided", () => {
+ renderStrict(
+
+
+ {ref => (
+
+
+
+ )}
+
+ ,
+ node
+ );
+ jest.runAllTimers();
+ expect(fakeFocus.mock.calls[0][0]).toMatchObject({
+ preventScroll: false
+ });
+ });
+
+ it("calls focus({ preventScroll: true }} when preventScroll = true", () => {
+ renderStrict(
+
+
+ {ref => (
+
+
+
+ )}
+
+ ,
+ node
+ );
+ jest.runAllTimers();
+ expect(fakeFocus.mock.calls[0][0]).toMatchObject({ preventScroll: true });
+ });
+
+ it("calls focus({ preventScroll: false }} when preventScroll = false", () => {
+ renderStrict(
+
+
+ {ref => (
+
+
+
+ )}
+
+ ,
+ node
+ );
+ jest.runAllTimers();
+ expect(fakeFocus.mock.calls[0][0]).toMatchObject({
+ preventScroll: false
+ });
+ });
+ });
+
+ describe("tabIndex", () => {
+ it("warns when ref element does not have a tabIndex attribute", () => {
+ jest.spyOn(console, "warn").mockImplementation(() => {});
+
+ renderStrict(
+
+
+ {ref => (
+
+
+
+ )}
+
+ ,
+ node
+ );
+
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'The ref must be assigned an element with the "tabIndex" attribute or be focusable by default in order to be focused. ' +
+ "Otherwise, the document's will be focused instead."
+ );
+ });
+
+ it("does not warn when ref element does not have a tabIndex attribute, but is already focusable", () => {
+ jest.spyOn(console, "warn").mockImplementation(() => {});
+
+ renderStrict(
+
+
+ {ref => (
+
+
+
+ )}
+
+ ,
+ node
+ );
+
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+ });
+});
diff --git a/packages/react-router-dom/modules/index.js b/packages/react-router-dom/modules/index.js
index 8974420264..e9ecbd7ed8 100644
--- a/packages/react-router-dom/modules/index.js
+++ b/packages/react-router-dom/modules/index.js
@@ -4,3 +4,4 @@ export { default as BrowserRouter } from "./BrowserRouter";
export { default as HashRouter } from "./HashRouter";
export { default as Link } from "./Link";
export { default as NavLink } from "./NavLink";
+export { default as Focus } from "./Focus";