[Invokers] Reland: Add HandleInvokeInternal on HTMLDialogElement (#45289)
(This relands
https://chromium-review.googlesource.com/c/chromium/src/+/4987322 after fixing timeout issues in
https://chromium-review.googlesource.com/c/chromium/src/+/5372478)
This adds support for the experimental `invoketarget` behavior for
HTMLDialogElement.
See explainer section here:
https://open-ui.org/components/invokers.explainer/#defaults.
This introduces new behavior for HTMLDialogElements such that:
- If an `invoketarget` points to a `<dialog>` (and the parent logic
fell through):
- If the `invokeaction` is `` (empty - the auto state) try to toggle the dialog as modal.
- If the `invokeaction` is `close`, try to close the dialog
- If the `invokeaction` is `showModal`, try to show the dialog as
modal.
Bug: 1494810
Change-Id: Ic178239b8597df8746d9b4cde8f2efe53290e3ea
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5389304
Commit-Queue: Keith Cirkel <chromium@keithcirkel.co.uk>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1280317}
Co-authored-by: Keith Cirkel <chromium@keithcirkel.co.uk>
diff --git a/html/semantics/invokers/invoketarget-on-dialog-behavior.tentative.html b/html/semantics/invokers/invoketarget-on-dialog-behavior.tentative.html
new file mode 100644
index 0000000..9f73e09
--- /dev/null
+++ b/html/semantics/invokers/invoketarget-on-dialog-behavior.tentative.html
@@ -0,0 +1,394 @@
+<!doctype html>
+<meta charset="utf-8" />
+<meta name="author" title="Keith Cirkel" href="mailto:wpt@keithcirkel.co.uk" />
+<meta name="timeout" content="long">
+<link rel="help" href="https://open-ui.org/components/invokers.explainer/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/invoker-utils.js"></script>
+
+<dialog id="invokee">
+ <button id="containedinvoker" invoketarget="invokee"></button>
+</dialog>
+<button id="invokerbutton" invoketarget="invokee"></button>
+
+<script>
+ function resetState() {
+ invokee.close();
+ try { invokee.hidePopover(); } catch {}
+ invokee.removeAttribute("popover");
+ invokerbutton.removeAttribute("invokeaction");
+ containedinvoker.removeAttribute("invokeaction");
+ }
+
+ // opening a dialog
+
+ [null, "", "showmodal", /* test case sensitivity */ "sHoWmOdAl"].forEach(
+ (action) => {
+ ["property", "attribute"].forEach((setType) => {
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ if (typeof action === "string") {
+ if (setType === "property") {
+ invokerbutton.invokeaction = action;
+ } else {
+ invokerbutton.setAttribute("invokeaction", action);
+ }
+ }
+ await clickOn(invokerbutton);
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) closed dialog opens as modal`,
+ );
+
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ invokee.addEventListener("invoke", (e) => e.preventDefault(), {
+ once: true,
+ });
+ if (typeof action === "string") {
+ if (setType === "property") {
+ invokerbutton.invokeaction = action;
+ } else {
+ invokerbutton.setAttribute("invokeaction", action);
+ }
+ }
+ await clickOn(invokerbutton);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) closed dialog with preventDefault is noop`,
+ );
+
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ invokee.addEventListener(
+ "invoke",
+ (e) => {
+ invokerbutton.setAttribute("invokeaction", "close");
+ },
+ { once: true },
+ );
+ if (typeof action === "string") {
+ if (setType === "property") {
+ invokerbutton.invokeaction = action;
+ } else {
+ invokerbutton.setAttribute("invokeaction", action);
+ }
+ }
+ await clickOn(invokerbutton);
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) while changing action still opens as modal`,
+ );
+ });
+ },
+ );
+
+ // closing an already open dialog
+
+ [null, "", "close", /* test case sensitivity */ "cLoSe"].forEach((action) => {
+ ["property", "attribute"].forEach((setType) => {
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ invokee.show();
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ if (typeof action === "string") {
+ if (setType === "property") {
+ containedinvoker.invokeaction = action;
+ } else {
+ containedinvoker.setAttribute("invokeaction", action);
+ }
+ }
+ await clickOn(containedinvoker);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking to close (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) open dialog closes`,
+ );
+
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ invokee.show();
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ if (typeof action === "string") {
+ if (setType === "property") {
+ containedinvoker.invokeaction = action;
+ } else {
+ containedinvoker.setAttribute("invokeaction", action);
+ }
+ }
+ invokee.addEventListener("invoke", (e) => e.preventDefault(), {
+ once: true,
+ });
+ await clickOn(containedinvoker);
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking to close (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) open dialog with preventDefault is no-op`,
+ );
+
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ invokee.showModal();
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ if (typeof action === "string") {
+ if (setType === "property") {
+ containedinvoker.invokeaction = action;
+ } else {
+ containedinvoker.setAttribute("invokeaction", action);
+ }
+ }
+ invokee.addEventListener("invoke", (e) => e.preventDefault(), {
+ once: true,
+ });
+ await clickOn(containedinvoker);
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking to close (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) open modal dialog with preventDefault is no-op`,
+ );
+
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ invokee.show();
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ if (typeof action === "string") {
+ if (setType === "property") {
+ containedinvoker.invokeaction = action;
+ } else {
+ containedinvoker.setAttribute("invokeaction", action);
+ }
+ }
+ invokee.addEventListener(
+ "invoke",
+ (e) => {
+ containedinvoker.setAttribute("invokeaction", "show");
+ },
+ { once: true },
+ );
+ await clickOn(containedinvoker);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking to close (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) open dialog while changing action still closes`,
+ );
+
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ invokee.showModal();
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ if (typeof action === "string") {
+ if (setType === "property") {
+ containedinvoker.invokeaction = action;
+ } else {
+ containedinvoker.setAttribute("invokeaction", action);
+ }
+ }
+ invokee.addEventListener(
+ "invoke",
+ (e) => {
+ containedinvoker.setAttribute("invokeaction", "show");
+ },
+ { once: true },
+ );
+ await clickOn(containedinvoker);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking to close (with invokeaction ${setType} as ${
+ action == null ? "auto" : action || "explicit empty"
+ }) open modal dialog while changing action still closes`,
+ );
+ });
+ });
+
+ // showmodal explicit behaviours
+
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ containedinvoker.setAttribute("invokeaction", "showModal");
+ invokee.show();
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ await clickOn(containedinvoker);
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ }, "invoking (as showmodal) open dialog is noop");
+
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ containedinvoker.setAttribute("invokeaction", "showmodal");
+ invokee.showModal();
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ invokee.addEventListener(
+ "invoke",
+ (e) => {
+ containedinvoker.setAttribute("invokeaction", "close");
+ },
+ { once: true },
+ );
+ await clickOn(invokerbutton);
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ }, "invoking (as showmodal) open modal, while changing action still a no-op");
+
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ invokerbutton.setAttribute("invokeaction", "showmodal");
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ invokee.setAttribute("popover", "auto");
+ await clickOn(invokerbutton);
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ }, "invoking (as showmodal) closed popover dialog opens as modal");
+
+ // close explicit behaviours
+
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ invokerbutton.setAttribute("invokeaction", "close");
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ await clickOn(containedinvoker);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ }, "invoking (as close) already closed dialog is noop");
+
+ // Open Popovers using Dialog actions
+ ["showmodal", "close", ""].forEach((action) => {
+ ["manual", "auto"].forEach((popoverState) => {
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ invokee.setAttribute("popover", popoverState);
+ invokee.showPopover();
+ containedinvoker.setAttribute("invokeaction", action);
+ assert_true(
+ invokee.matches(":popover-open"),
+ "invokee :popover-open",
+ );
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ invokee.addEventListener("invoke", (e) => e.preventDefault(), {
+ once: true,
+ });
+ await clickOn(containedinvoker);
+ assert_true(
+ invokee.matches(":popover-open"),
+ "invokee :popover-open",
+ );
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (as ${
+ action || "explicit empty"
+ }) dialog as open popover=${popoverState} is noop`,
+ );
+ });
+ });
+
+ // Elements being disconnected during invoke steps
+ ["showmodal", "close", ""].forEach((action) => {
+ promise_test(
+ async function (t) {
+ t.add_cleanup(() => {
+ document.body.prepend(invokee);
+ resetState();
+ });
+ const invokee = document.querySelector("#invokee");
+ invokerbutton.setAttribute("invokeaction", action);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ invokee.addEventListener(
+ "invoke",
+ (e) => {
+ invokee.remove();
+ },
+ {
+ once: true,
+ },
+ );
+ await clickOn(invokerbutton);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (as ${
+ action || "explicit empty"
+ }) dialog that is removed is noop`,
+ );
+
+ promise_test(
+ async function (t) {
+ const invokerbutton = document.createElement("button");
+ invokerbutton.invokeTargetElement = invokee;
+ invokerbutton.setAttribute("invokeaction", action);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ await clickOn(invokerbutton);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (as ${
+ action || "explicit empty"
+ }) dialog from a detached invoker`,
+ );
+
+ promise_test(
+ async function (t) {
+ const invokerbutton = document.createElement("button");
+ const invokee = document.createElement("dialog");
+ invokerbutton.invokeTargetElement = invokee;
+ invokerbutton.setAttribute("invokeaction", action);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ await clickOn(invokerbutton);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (as ${
+ action || "explicit empty"
+ }) detached dialog from a detached invoker`,
+ );
+ });
+</script>
diff --git a/html/semantics/invokers/invoketarget-on-dialog-invalid-behavior.tentative.html b/html/semantics/invokers/invoketarget-on-dialog-invalid-behavior.tentative.html
new file mode 100644
index 0000000..af84c22
--- /dev/null
+++ b/html/semantics/invokers/invoketarget-on-dialog-invalid-behavior.tentative.html
@@ -0,0 +1,120 @@
+<!doctype html>
+<meta charset="utf-8" />
+<meta name="author" title="Keith Cirkel" href="mailto:wpt@keithcirkel.co.uk" />
+<meta name="timeout" content="long">
+<link rel="help" href="https://open-ui.org/components/invokers.explainer/" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/invoker-utils.js"></script>
+
+<dialog id="invokee">
+ <button id="containedinvoker" invoketarget="invokee"></button>
+</dialog>
+<button id="invokerbutton" invoketarget="invokee"></button>
+
+<script>
+ function resetState() {
+ invokee.close();
+ try { invokee.hidePopover(); } catch {}
+ invokee.removeAttribute("popover");
+ invokerbutton.removeAttribute("invokeaction");
+ containedinvoker.removeAttribute("invokeaction");
+ }
+
+ // invalid
+ [
+ "foo",
+ "foo-bar",
+ "auto",
+ "showpopover",
+ "hidepopover",
+ "togglepopover",
+ "showpicker",
+ ].forEach((action) => {
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ invokerbutton.setAttribute("invokeaction", action);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ await clickOn(invokerbutton);
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ }, `invoking (as ${action}) on dialog does nothing`);
+
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ containedinvoker.setAttribute("invokeaction", action);
+ invokee.show();
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ await clickOn(containedinvoker);
+ assert_true(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ }, `invoking (as ${action}) on open dialog does nothing`);
+
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ containedinvoker.setAttribute("invokeaction", action);
+ invokee.showModal();
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ await clickOn(containedinvoker);
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ }, `invoking (as ${action}) on open modal dialog does nothing`);
+
+ promise_test(async function (t) {
+ t.add_cleanup(resetState);
+ containedinvoker.setAttribute("invokeaction", action);
+ invokee.showModal();
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ invokee.addEventListener(
+ "invoke",
+ (e) => {
+ containedinvoker.setAttribute("invokeaction", "");
+ },
+ { once: true },
+ );
+ await clickOn(containedinvoker);
+ assert_true(invokee.open, "invokee.open");
+ assert_true(invokee.matches(":modal"), "invokee :modal");
+ }, `invoking (as ${action}) on open modal while changing the attributer does nothing`);
+ });
+
+ // Open Popovers using Dialog actions
+ ["showmodal", "close", ""].forEach((action) => {
+ ["manual", "auto"].forEach((popoverState) => {
+ promise_test(
+ async function (t) {
+ t.add_cleanup(resetState);
+ invokee.setAttribute("popover", popoverState);
+ invokee.showPopover();
+ containedinvoker.setAttribute("invokeaction", action);
+ assert_true(
+ invokee.matches(":popover-open"),
+ "invokee :popover-open",
+ );
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ invokee.addEventListener("invoke", (e) => e.preventDefault(), {
+ once: true,
+ });
+ await clickOn(containedinvoker);
+ assert_true(
+ invokee.matches(":popover-open"),
+ "invokee :popover-open",
+ );
+ assert_false(invokee.open, "invokee.open");
+ assert_false(invokee.matches(":modal"), "invokee :modal");
+ },
+ `invoking (as ${
+ action || "explicit empty"
+ }) dialog as open popover=${popoverState} is noop`,
+ );
+ });
+ });
+</script>
diff --git a/html/semantics/invokers/resources/invoker-utils.js b/html/semantics/invokers/resources/invoker-utils.js
index 3179455..8420f24 100644
--- a/html/semantics/invokers/resources/invoker-utils.js
+++ b/html/semantics/invokers/resources/invoker-utils.js
@@ -2,9 +2,13 @@
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
async function clickOn(element) {
- const actions = new test_driver.Actions();
await waitForRender();
- await actions.pointerMove(0, 0, {origin: element})
+ let rect = element.getBoundingClientRect();
+ let actions = new test_driver.Actions();
+ // FIXME: Switch to pointerMove(0, 0, {origin: element}) once
+ // https://github.com/web-platform-tests/wpt/issues/41257 is fixed.
+ await actions
+ .pointerMove(Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2), {})
.pointerDown({button: actions.ButtonType.LEFT})
.pointerUp({button: actions.ButtonType.LEFT})
.send();