From eb20444cfacc5060e5bb5be89ac6db84bff0af66 Mon Sep 17 00:00:00 2001 From: Matthew Holloway Date: Tue, 10 Dec 2019 05:32:19 +1300 Subject: [PATCH] [fixed] Focus trap when reentering document (#742) (#791) * [added] Focus trap when reentering document (#742) --- src/components/ModalPortal.js | 6 ++++ src/helpers/bodyTrap.js | 52 ++++++++++++++++++++++++++++ src/helpers/portalOpenInstances.js | 55 ++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 src/helpers/bodyTrap.js create mode 100644 src/helpers/portalOpenInstances.js diff --git a/src/components/ModalPortal.js b/src/components/ModalPortal.js index a510a019..dea75c26 100644 --- a/src/components/ModalPortal.js +++ b/src/components/ModalPortal.js @@ -5,6 +5,8 @@ import scopeTab from "../helpers/scopeTab"; import * as ariaAppHider from "../helpers/ariaAppHider"; import * as classList from "../helpers/classList"; import SafeHTMLElement from "../helpers/safeHTMLElement"; +import portalOpenInstances from "../helpers/portalOpenInstances"; +import "../helpers/bodyTrap"; // so that our CSS is statically analyzable const CLASS_NAMES = { @@ -151,6 +153,8 @@ export default class ModalPortal extends Component { ariaHiddenInstances += 1; ariaAppHider.hide(appElement); } + + portalOpenInstances.register(this); } afterClose = () => { @@ -191,6 +195,8 @@ export default class ModalPortal extends Component { if (this.props.onAfterClose) { this.props.onAfterClose(); } + + portalOpenInstances.deregister(this); }; open = () => { diff --git a/src/helpers/bodyTrap.js b/src/helpers/bodyTrap.js new file mode 100644 index 00000000..b990823b --- /dev/null +++ b/src/helpers/bodyTrap.js @@ -0,0 +1,52 @@ +import portalOpenInstances from "./portalOpenInstances"; +// Body focus trap see Issue #742 + +let before, + after, + instances = []; + +function focusContent() { + if (instances.length === 0) { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.warn(`React-Modal: Open instances > 0 expected`); + } + return; + } + instances[instances.length - 1].focusContent(); +} + +function bodyTrap(eventType, openInstances) { + if (!before || !after) { + before = document.createElement("div"); + before.setAttribute("data-react-modal-body-trap", ""); + before.style.position = "absolute"; + before.style.opacity = "0"; + before.setAttribute("tabindex", "0"); + before.addEventListener("focus", focusContent); + after = before.cloneNode(); + after.addEventListener("focus", focusContent); + } + + instances = openInstances; + + if (instances.length > 0) { + // Add focus trap + if (document.body.firstChild !== before) { + document.body.insertBefore(before, document.body.firstChild); + } + if (document.body.lastChild !== after) { + document.body.appendChild(after); + } + } else { + // Remove focus trap + if (before.parentElement) { + before.parentElement.removeChild(before); + } + if (after.parentElement) { + after.parentElement.removeChild(after); + } + } +} + +portalOpenInstances.subscribe(bodyTrap); diff --git a/src/helpers/portalOpenInstances.js b/src/helpers/portalOpenInstances.js new file mode 100644 index 00000000..f3955f90 --- /dev/null +++ b/src/helpers/portalOpenInstances.js @@ -0,0 +1,55 @@ +// Tracks portals that are open and emits events to subscribers + +class PortalOpenInstances { + constructor() { + this.openInstances = []; + this.subscribers = []; + } + + register = openInstance => { + if (this.openInstances.indexOf(openInstance) !== -1) { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.warn( + `React-Modal: Cannot register modal instance that's already open` + ); + } + return; + } + this.openInstances.push(openInstance); + this.emit("register"); + }; + + deregister = openInstance => { + const index = this.openInstances.indexOf(openInstance); + if (index === -1) { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.warn( + `React-Modal: Unable to deregister ${openInstance} as it was never registered` + ); + } + return; + } + this.openInstances.splice(index, 1); + this.emit("deregister"); + }; + + subscribe = callback => { + this.subscribers.push(callback); + }; + + emit = eventType => { + this.subscribers.forEach(subscriber => + subscriber( + eventType, + // shallow copy to avoid accidental mutation + this.openInstances.slice() + ) + ); + }; +} + +const portalOpenInstances = new PortalOpenInstances(); + +export default portalOpenInstances;