diff --git a/app/webpacker/controllers/popout_controller.js b/app/webpacker/controllers/popout_controller.js index 06c5156ce4..a2fac4e58e 100644 --- a/app/webpacker/controllers/popout_controller.js +++ b/app/webpacker/controllers/popout_controller.js @@ -10,6 +10,20 @@ export default class PopoutController extends Controller { // Show when click or down-arrow on button this.buttonTarget.addEventListener("click", this.show.bind(this)); this.buttonTarget.addEventListener("keydown", this.showIfDownArrow.bind(this)); + + // Close when click or tab outside of dialog. Run async (don't block primary event handlers). + this.closeIfOutsideBound = this.closeIfOutside.bind(this); // Store reference for removing listeners later. + document.addEventListener("click", this.closeIfOutsideBound, { passive: true }); + document.addEventListener("focusin", this.closeIfOutsideBound, { passive: true }); + } + + disconnect() { + // Clean up handlers registered outside the controller element. + // (jest cleans up document too early) + if (document) { + document.removeEventListener("click", this.closeIfOutsideBound); + document.removeEventListener("focusin", this.closeIfOutsideBound); + } } show(e) { @@ -23,4 +37,10 @@ export default class PopoutController extends Controller { this.show(e); } } + + closeIfOutside(e) { + if (!this.dialogTarget.contains(e.target)) { + this.dialogTarget.style.display = "none"; + } + } } diff --git a/spec/javascripts/stimulus/popout_controller_test.js b/spec/javascripts/stimulus/popout_controller_test.js index f3f332143d..59708a8e46 100644 --- a/spec/javascripts/stimulus/popout_controller_test.js +++ b/spec/javascripts/stimulus/popout_controller_test.js @@ -20,16 +20,19 @@ describe("PopoutController", () => { + `; const button = document.getElementById("button"); const input1 = document.getElementById("input1"); const input2 = document.getElementById("input2"); + const input3 = document.getElementById("input3"); }); describe("Show", () => { it("shows the dialog on click", () => { - button.click(); + // button.click(); // For some reason this fails due to passive: true, but works in real life. + button.dispatchEvent(new Event("click")); expect(dialog.style.display).toBe("block"); // visible }); @@ -46,4 +49,32 @@ describe("PopoutController", () => { expect(dialog.style.display).toBe("none"); // not visible }); }); + + describe("Close", () => { + beforeEach(() => { + button.dispatchEvent(new Event("click")); // Dialog is open + }) + + it("closes the dialog when click outside", () => { + input3.click(); + + expect(dialog.style.display).toBe("none"); // not visible + }); + + it("closes the dialog when focusing another field (eg with tab)", () => { + input3.focus(); + + expect(dialog.style.display).toBe("none"); // not visible + }); + + it("doesn't close the dialog when focusing internal field", () => { + input2.focus(); + + expect(dialog.style.display).toBe("block"); // visible + }); + }); + + describe("Cleaning up", () => { + // unable to test disconnect + }); });