diff --git a/app/javascript/app/components/focus-trap-proxy.js b/app/javascript/app/components/focus-trap-proxy.js deleted file mode 100644 index 8c65257ec47..00000000000 --- a/app/javascript/app/components/focus-trap-proxy.js +++ /dev/null @@ -1,51 +0,0 @@ -import createFocusTrap from 'focus-trap'; - -function FocusTrapProxy() { - const focusables = []; - let activated = []; - - return function makeTrap(el, options = {}) { - const ownTrap = createFocusTrap(el, options); - - focusables.push(ownTrap); - - return { - activate() { - focusables.forEach((trap) => trap.deactivate()); - - activated.push(ownTrap); - - ownTrap.activate(); - - return ownTrap; - }, - - deactivate(opts = {}) { - const deactivatedTrap = ownTrap.deactivate(opts); - - // `deactivate` will return a valid trap object if it is available to be - // deactivated. If not, it returns a falsey value. If nothing was deactivated, - // bail out. - if (!deactivatedTrap) { - return false; - } - - activated = activated.filter((activatedTrap) => activatedTrap !== ownTrap); - - if (activated.length) { - activated[activated.length - 1].activate(); - } - - return deactivatedTrap; - }, - - pause() { - ownTrap.pause(); - }, - }; - }; -} - -const focusTrapProxy = FocusTrapProxy.call(FocusTrapProxy); - -export default focusTrapProxy; diff --git a/app/javascript/app/components/index.js b/app/javascript/app/components/index.js index 69c77cfcefb..c4a4bc5facb 100644 --- a/app/javascript/app/components/index.js +++ b/app/javascript/app/components/index.js @@ -1,10 +1,4 @@ -import focusTrapProxy from './focus-trap-proxy'; -import modal from './modal'; +import Modal from './modal'; window.LoginGov = window.LoginGov || {}; -const { LoginGov } = window; -const trapModal = modal(focusTrapProxy); - -LoginGov.Modal = trapModal; - -export { trapModal as Modal }; +window.LoginGov.Modal = Modal; diff --git a/app/javascript/app/components/modal.js b/app/javascript/app/components/modal.js index 6a335cd6ba9..2676f67bb52 100644 --- a/app/javascript/app/components/modal.js +++ b/app/javascript/app/components/modal.js @@ -1,4 +1,5 @@ import 'classlist.js'; +import { createFocusTrap } from 'focus-trap'; import Events from '../utils/events'; const STATES = { @@ -6,43 +7,41 @@ const STATES = { SHOW: 'show', }; -function modal(focusTrap) { - return class extends Events { - constructor(options) { - super(); +class Modal extends Events { + constructor(options) { + super(); - this.el = document.querySelector(options.el); - this.shown = false; - this.trap = focusTrap(this.el, { escapeDeactivates: false }); - } - - toggle() { - if (this.shown) { - this.hide(); - } else { - this.show(); - } - } - - show(target) { - this.setElementVisibility(target, true); - this.emit(STATES.SHOW); - } - - hide(target) { - this.setElementVisibility(target, false); - this.emit(STATES.HIDE); - } - - setElementVisibility(target = null, showing) { - const el = target || this.el; + this.el = document.querySelector(options.el); + this.shown = false; + this.trap = createFocusTrap(this.el, { escapeDeactivates: false }); + } - this.shown = showing; - el.classList[showing ? 'remove' : 'add']('display-none'); - document.body.classList[showing ? 'add' : 'remove']('modal-open'); - this.trap[showing ? 'activate' : 'deactivate'](); + toggle() { + if (this.shown) { + this.hide(); + } else { + this.show(); } - }; + } + + show(target) { + this.setElementVisibility(target, true); + this.emit(STATES.SHOW); + } + + hide(target) { + this.setElementVisibility(target, false); + this.emit(STATES.HIDE); + } + + setElementVisibility(target = null, showing) { + const el = target || this.el; + + this.shown = showing; + el.classList[showing ? 'remove' : 'add']('display-none'); + document.body.classList[showing ? 'add' : 'remove']('modal-open'); + this.trap[showing ? 'activate' : 'deactivate'](); + } } -export default modal; +export default Modal; diff --git a/app/javascript/packages/document-capture/components/full-screen.jsx b/app/javascript/packages/document-capture/components/full-screen.jsx index 992000313fc..87d743c2fdb 100644 --- a/app/javascript/packages/document-capture/components/full-screen.jsx +++ b/app/javascript/packages/document-capture/components/full-screen.jsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useCallback } from 'react'; -import createFocusTrap from 'focus-trap'; +import { createFocusTrap } from 'focus-trap'; import useI18n from '../hooks/use-i18n'; import useAsset from '../hooks/use-asset'; diff --git a/app/javascript/packages/document-capture/package.json b/app/javascript/packages/document-capture/package.json index fa7a47b7eab..a8940e7472d 100644 --- a/app/javascript/packages/document-capture/package.json +++ b/app/javascript/packages/document-capture/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "dependencies": { - "focus-trap": "^2.3.0", + "focus-trap": "^6.0.1", "react": "^16.13.1" } } diff --git a/package.json b/package.json index 9cd5dde57d1..259e9c53b75 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "classlist.js": "^1.1.20150312", "cleave.js": "^1.5.3", "clipboard": "^1.6.1", - "focus-trap": "^2.3.0", + "focus-trap": "^6.0.1", "hint.css": "^2.3.2", "identity-style-guide": "^2.1.5", "intl-tel-input": "^16.0.7", diff --git a/spec/javascripts/app/components/focus-trap-proxy_spec.js b/spec/javascripts/app/components/focus-trap-proxy_spec.js deleted file mode 100644 index a7d9b3be977..00000000000 --- a/spec/javascripts/app/components/focus-trap-proxy_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); - -const { stub } = sinon; - -describe('focusTrap', () => { - let proxy; - - beforeEach(() => { - proxy = proxyquire('../../../../app/javascript/app/components/focus-trap-proxy', { - // Mock external focus-trap library - 'focus-trap': () => ({ - deactivate: stub(), - activate: stub(), - }), - }).default; - }); - - context('#deactivate', () => { - it('proxies to `deactivate` and reactivates the last active trap', () => { - const trapA = proxy('foo1'); - const trapB = proxy('foo2'); - - const aFocusTrap = trapA.activate(); - const bFocusTrap = trapB.activate(); - - bFocusTrap.deactivate.returns(bFocusTrap); - - trapB.deactivate(); - - expect(aFocusTrap.activate.callCount).to.be.equal(2); - expect(aFocusTrap.deactivate.callCount).to.be.equal(2); - expect(bFocusTrap.activate.callCount).to.be.equal(1); - expect(bFocusTrap.deactivate.callCount).to.equal(3); - }); - }); -}); diff --git a/spec/javascripts/app/components/modal-spec.js b/spec/javascripts/app/components/modal-spec.js new file mode 100644 index 00000000000..2f8ed284750 --- /dev/null +++ b/spec/javascripts/app/components/modal-spec.js @@ -0,0 +1,90 @@ +import { waitFor } from '@testing-library/dom'; +import BaseModal from '../../../../app/javascript/app/components/modal'; +import { useCleanDOM } from '../../support/dom'; +import { useSandbox } from '../../support/sinon'; + +describe('components/modal', () => { + useCleanDOM(); + const sandbox = useSandbox(); + + class Modal extends BaseModal { + static instances = []; + + constructor(...args) { + super(...args); + Modal.instances.push(this); + } + } + + function createModalContainer(id = 'modal') { + const container = document.createElement('div'); + container.id = id; + container.className = 'modal display-none'; + container.innerHTML = ` + + `; + + return container; + } + + beforeEach(() => { + document.body.appendChild(createModalContainer()); + }); + + afterEach(() => { + Modal.instances.forEach((instance) => { + instance.off(); + if (instance.shown) { + instance.hide(); + } + }); + }); + + it('shows with initial focus', async () => { + const onShow = sandbox.stub(); + const modal = new Modal({ el: '#modal' }); + modal.on('show', onShow); + modal.show(); + + await waitFor(() => expect(document.activeElement.nodeName).to.equal('BUTTON')); + expect(onShow.called).to.be.true(); + expect(document.activeElement.textContent).to.equal('Yes'); + const container = document.activeElement.closest('#modal'); + expect(container.classList.contains('display-none')).to.be.false(); + expect(document.body.classList.contains('modal-open')).to.be.true(); + }); + + it('allows interaction in most recently activated focus trap', async () => { + document.body.appendChild(createModalContainer('modal2')); + const modal = new Modal({ el: '#modal' }); + const modal2 = new Modal({ el: '#modal2' }); + + modal.show(); + + await waitFor(() => expect(document.activeElement.closest('#modal')).to.be.ok()); + + modal2.show(); + + await waitFor(() => expect(document.activeElement.closest('#modal2')).to.be.ok()); + + await new Promise((resolve) => { + document.activeElement.addEventListener('click', (event) => { + if (!event.defaultPrevented) { + resolve(); + } + }); + + document.activeElement.click(); + }); + }); +}); diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js index 467148a7d9f..f15d3729871 100644 --- a/spec/javascripts/spec_helper.js +++ b/spec/javascripts/spec_helper.js @@ -15,6 +15,8 @@ global.window = dom.window; global.window.fetch = () => Promise.reject(new Error('Fetch must be stubbed')); global.navigator = window.navigator; global.document = window.document; +global.Document = window.Document; +global.Element = window.Element; global.getComputedStyle = window.getComputedStyle; global.self = window; diff --git a/yarn.lock b/yarn.lock index e0e80514127..613764b6a76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4206,12 +4206,12 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" -focus-trap@^2.3.0: - version "2.4.6" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.6.tgz#332b475b317cec6a4a129f5307ce7ebc0da90b40" - integrity sha512-vWZTPtBU6pBoyWZDRZJHkXsyP2ZCZBHE3DRVXnSVdQKH/mcDtu9S5Kz8CUDyIqpfZfLEyI9rjKJLnc4Y40BRBg== +focus-trap@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.0.1.tgz#f90725e4bb62ddab16e685b02b43b823858a4c0a" + integrity sha512-BOksLMPK/jlXD389jYPlZHAqiDdy9W63EBQfVIouME8s3UZsCEmq3NA53qt30S4i72sRcDjc1FHtast0TmqhRA== dependencies: - tabbable "^1.0.3" + tabbable "^5.0.0" follow-redirects@^1.0.0: version "1.12.1" @@ -8897,10 +8897,10 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== -tabbable@^1.0.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.3.tgz#0e4ee376f3631e42d7977a074dbd2b3827843081" - integrity sha512-nOWwx35/JuDI4ONuF0ZTo6lYvI0fY0tZCH1ErzY2EXfu4az50ZyiUX8X073FLiZtmWUVlkRnuXsehjJgCw9tYg== +tabbable@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.0.0.tgz#862b6f33a625da45d7c648cff0262dab453d8b0c" + integrity sha512-+TJTMpkHRCWkMGczHHVEfzBYCsVOiBjd3vle55AT4H299BhdJDLFqcYmr7S6kt5EGhT8gAywSC5gPUBDNvtl7w== table@^5.2.3: version "5.4.6"