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
+ });
});