commit cfa1a028c8c84a05d57e8edfbdc9d0ed8bcf9dea
parent 37698d3f7eaac45bbd126f980c0ea6037d7a6798
Author: Daniil Sakhapov <sakhapov@chromium.org>
Date: Mon, 10 Nov 2025 22:20:10 +0000
Bug 1999028 [wpt PR 55948] - Add scroll command support for HTML command invokers, a=testonly
Automatic update from web-platform-tests
Add scroll command support for HTML command invokers
Implements page-up, page-down, page-left, page-right and logical
direction commands (page-block-start/end, page-inline-start/end)
for HTML command invokers.
This allows buttons with commandfor and command attributes to
scroll target elements using declarative syntax.
Bug: 457939344
Change-Id: I6830ec2be40251647db5affd52bf84f955b774ac
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7124765
Commit-Queue: Daniil Sakhapov <sakhapov@chromium.org>
Reviewed-by: Robert Flack <flackr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1541924}
--
wpt-commits: 965fa8ebe852e5e0a1511782b1a020f65b963755
wpt-pr: 55948
Diffstat:
1 file changed, 544 insertions(+), 0 deletions(-)
diff --git a/testing/web-platform/tests/html/semantics/the-button-element/command-and-commandfor/on-scroll-behavior.tentative.html b/testing/web-platform/tests/html/semantics/the-button-element/command-and-commandfor/on-scroll-behavior.tentative.html
@@ -0,0 +1,543 @@
+<!doctype html>
+<meta charset="utf-8" />
+<meta name="author" title="Chromium" href="https://chromium.org" />
+<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/invoker-utils.js"></script>
+
+<style>
+ .scroll-container {
+ width: 200px;
+ height: 200px;
+ overflow: auto;
+ border: 1px solid black;
+ }
+
+ .scroll-content {
+ width: 1000px;
+ height: 1000px;
+ background: linear-gradient(to bottom right, red, blue);
+ }
+
+ .scroll-container-horizontal {
+ width: 200px;
+ height: 100px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ border: 1px solid black;
+ }
+
+ .scroll-content-horizontal {
+ width: 1000px;
+ height: 100px;
+ background: linear-gradient(to right, red, blue);
+ }
+
+ .scroll-container-vertical {
+ width: 100px;
+ height: 200px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ border: 1px solid black;
+ }
+
+ .scroll-content-vertical {
+ width: 100px;
+ height: 1000px;
+ background: linear-gradient(to bottom, red, blue);
+ }
+
+ .rtl {
+ direction: rtl;
+ }
+
+ .vertical-writing {
+ writing-mode: vertical-rl;
+ }
+</style>
+
+<!-- Basic scroll container -->
+<div id="scrollcontainer" class="scroll-container">
+ <div class="scroll-content"></div>
+</div>
+<button id="pageup" commandfor="scrollcontainer" command="page-up">Page Up</button>
+<button id="pagedown" commandfor="scrollcontainer" command="page-down">Page Down</button>
+<button id="pageleft" commandfor="scrollcontainer" command="page-left">Page Left</button>
+<button id="pageright" commandfor="scrollcontainer" command="page-right">Page Right</button>
+
+<!-- Horizontal only scroll container -->
+<div id="horizontalcontainer" class="scroll-container-horizontal">
+ <div class="scroll-content-horizontal"></div>
+</div>
+<button id="hpageleft" commandfor="horizontalcontainer" command="page-left">Page Left</button>
+<button id="hpageright" commandfor="horizontalcontainer" command="page-right">Page Right</button>
+
+<!-- Vertical only scroll container -->
+<div id="verticalcontainer" class="scroll-container-vertical">
+ <div class="scroll-content-vertical"></div>
+</div>
+<button id="vpageup" commandfor="verticalcontainer" command="page-up">Page Up</button>
+<button id="vpagedown" commandfor="verticalcontainer" command="page-down">Page Down</button>
+
+<!-- Logical direction tests -->
+<div id="logicalcontainer" class="scroll-container">
+ <div class="scroll-content"></div>
+</div>
+<button id="blockstart" commandfor="logicalcontainer" command="page-block-start">Block Start</button>
+<button id="blockend" commandfor="logicalcontainer" command="page-block-end">Block End</button>
+<button id="inlinestart" commandfor="logicalcontainer" command="page-inline-start">Inline Start</button>
+<button id="inlineend" commandfor="logicalcontainer" command="page-inline-end">Inline End</button>
+
+<!-- RTL container -->
+<div id="rtlcontainer" class="scroll-container rtl">
+ <div class="scroll-content"></div>
+</div>
+<button id="rtlinlinestart" commandfor="rtlcontainer" command="page-inline-start">Inline Start (RTL)</button>
+<button id="rtlinlineend" commandfor="rtlcontainer" command="page-inline-end">Inline End (RTL)</button>
+
+<!-- Vertical writing mode container -->
+<div id="verticalwritingcontainer" class="scroll-container vertical-writing">
+ <div class="scroll-content"></div>
+</div>
+<button id="vwblockstart" commandfor="verticalwritingcontainer" command="page-block-start">Block Start (VW)</button>
+<button id="vwblockend" commandfor="verticalwritingcontainer" command="page-block-end">Block End (VW)</button>
+
+<script>
+ function resetScrollPosition(container) {
+ container.scrollTop = 0;
+ container.scrollLeft = 0;
+ }
+
+ // Test page-up command
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ scrollcontainer.scrollTop = 400;
+ const initialScrollTop = scrollcontainer.scrollTop;
+ pageup.click();
+ assert_less_than(scrollcontainer.scrollTop, initialScrollTop, "Scroll position should decrease");
+ }, "page-up command scrolls up");
+
+ // Test page-down command
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const initialScrollTop = scrollcontainer.scrollTop;
+ pagedown.click();
+ assert_greater_than(scrollcontainer.scrollTop, initialScrollTop, "Scroll position should increase");
+ }, "page-down command scrolls down");
+
+ // Test page-left command
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ scrollcontainer.scrollLeft = 400;
+ const initialScrollLeft = scrollcontainer.scrollLeft;
+ pageleft.click();
+ assert_less_than(scrollcontainer.scrollLeft, initialScrollLeft, "Scroll position should decrease");
+ }, "page-left command scrolls left");
+
+ // Test page-right command
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const initialScrollLeft = scrollcontainer.scrollLeft;
+ pageright.click();
+ assert_greater_than(scrollcontainer.scrollLeft, initialScrollLeft, "Scroll position should increase");
+ }, "page-right command scrolls right");
+
+ // Test that page-up doesn't scroll horizontally
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ scrollcontainer.scrollTop = 400;
+ scrollcontainer.scrollLeft = 200;
+ const initialScrollLeft = scrollcontainer.scrollLeft;
+ pageup.click();
+ assert_equals(scrollcontainer.scrollLeft, initialScrollLeft, "Horizontal scroll should not change");
+ }, "page-up command doesn't affect horizontal scroll");
+
+ // Test that page-left doesn't scroll vertically
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ scrollcontainer.scrollTop = 200;
+ scrollcontainer.scrollLeft = 400;
+ const initialScrollTop = scrollcontainer.scrollTop;
+ pageleft.click();
+ assert_equals(scrollcontainer.scrollTop, initialScrollTop, "Vertical scroll should not change");
+ }, "page-left command doesn't affect vertical scroll");
+
+ // Test horizontal-only container
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(horizontalcontainer));
+ const initialScrollLeft = horizontalcontainer.scrollLeft;
+ hpageright.click();
+ assert_greater_than(horizontalcontainer.scrollLeft, initialScrollLeft, "Horizontal scroll should increase");
+ assert_equals(horizontalcontainer.scrollTop, 0, "Vertical scroll should remain 0");
+ }, "page-right works on horizontal-only container");
+
+ // Test vertical-only container
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(verticalcontainer));
+ const initialScrollTop = verticalcontainer.scrollTop;
+ vpagedown.click();
+ assert_greater_than(verticalcontainer.scrollTop, initialScrollTop, "Vertical scroll should increase");
+ assert_equals(verticalcontainer.scrollLeft, 0, "Horizontal scroll should remain 0");
+ }, "page-down works on vertical-only container");
+
+ // Test page-block-end (should scroll down in horizontal writing mode)
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(logicalcontainer));
+ const initialScrollTop = logicalcontainer.scrollTop;
+ blockend.click();
+ assert_greater_than(logicalcontainer.scrollTop, initialScrollTop, "Scroll position should increase");
+ }, "page-block-end scrolls down in horizontal writing mode");
+
+ // Test page-block-start (should scroll up in horizontal writing mode)
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(logicalcontainer));
+ logicalcontainer.scrollTop = 400;
+ const initialScrollTop = logicalcontainer.scrollTop;
+ blockstart.click();
+ assert_less_than(logicalcontainer.scrollTop, initialScrollTop, "Scroll position should decrease");
+ }, "page-block-start scrolls up in horizontal writing mode");
+
+ // Test page-inline-end (should scroll right in LTR)
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(logicalcontainer));
+ const initialScrollLeft = logicalcontainer.scrollLeft;
+ inlineend.click();
+ assert_greater_than(logicalcontainer.scrollLeft, initialScrollLeft, "Scroll position should increase");
+ }, "page-inline-end scrolls right in LTR");
+
+ // Test page-inline-start (should scroll left in LTR)
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(logicalcontainer));
+ logicalcontainer.scrollLeft = 400;
+ const initialScrollLeft = logicalcontainer.scrollLeft;
+ inlinestart.click();
+ assert_less_than(logicalcontainer.scrollLeft, initialScrollLeft, "Scroll position should decrease");
+ }, "page-inline-start scrolls left in LTR");
+
+ // Test RTL inline directions
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(rtlcontainer));
+ // In RTL, inline-end should scroll left (in the visual sense)
+ const initialScrollLeft = rtlcontainer.scrollLeft;
+ rtlinlineend.click();
+ // Note: RTL scrolling behavior can vary, but the command should work
+ assert_not_equals(rtlcontainer.scrollLeft, initialScrollLeft, "Scroll position should change");
+ }, "page-inline-end works in RTL container");
+
+ // Test case insensitivity
+ ["page-up", "PAGE-UP", "PaGe-Up"].forEach((command) => {
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "scrollcontainer");
+ button.setAttribute("command", command);
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ scrollcontainer.scrollTop = 400;
+ const initialScrollTop = scrollcontainer.scrollTop;
+ button.click();
+ assert_less_than(scrollcontainer.scrollTop, initialScrollTop, "Scroll should work with " + command);
+ }, `scroll command is case-insensitive: ${command}`);
+ });
+
+ // Test preventDefault
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ scrollcontainer.addEventListener("command", (e) => e.preventDefault(), { once: true });
+ const initialScrollTop = scrollcontainer.scrollTop;
+ pagedown.click();
+ assert_equals(scrollcontainer.scrollTop, initialScrollTop, "Scroll should not change when prevented");
+ }, "preventDefault stops scroll command");
+
+ // Test that scroll doesn't happen if commandfor is invalid
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "nonexistent");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ const initialScrollTop = scrollcontainer.scrollTop;
+ button.click();
+ assert_equals(scrollcontainer.scrollTop, initialScrollTop, "Scroll should not happen with invalid commandfor");
+ }, "scroll command requires valid commandfor target");
+
+ // Test that scroll doesn't happen on non-scrollable element
+ test(function (t) {
+ const nonscrollable = document.createElement("div");
+ nonscrollable.id = "nonscrollable";
+ nonscrollable.textContent = "Not scrollable";
+ document.body.appendChild(nonscrollable);
+ t.add_cleanup(() => nonscrollable.remove());
+
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "nonscrollable");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw or cause issues
+ button.click();
+ assert_equals(nonscrollable.scrollTop, 0, "Non-scrollable element should remain at 0");
+ }, "scroll command on non-scrollable element does nothing");
+
+ // Test scroll amount is reasonable (approximately one page)
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const initialScrollTop = scrollcontainer.scrollTop;
+ const containerHeight = scrollcontainer.clientHeight;
+ pagedown.click();
+ const scrollDistance = scrollcontainer.scrollTop - initialScrollTop;
+
+ // Scroll should be at least 80% of container height (allowing for some overlap)
+ assert_greater_than(scrollDistance, containerHeight * 0.8,
+ "Scroll distance should be approximately one page");
+ // And not more than 1.2x container height
+ assert_less_than(scrollDistance, containerHeight * 1.2,
+ "Scroll distance should not be much more than one page");
+ }, "scroll amount is approximately one page");
+
+ // Edge case: commandfor references non-existent element
+ test(function (t) {
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "this-element-does-not-exist");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw
+ assert_equals(button.click(), undefined, "Click should not throw");
+ }, "scroll command with non-existent commandfor target doesn't throw");
+
+ // Edge case: commandfor is empty string
+ test(function (t) {
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw
+ assert_equals(button.click(), undefined, "Click should not throw");
+ }, "scroll command with empty commandfor doesn't throw");
+
+ // Edge case: commandfor is whitespace
+ test(function (t) {
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", " ");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw
+ assert_equals(button.click(), undefined, "Click should not throw");
+ }, "scroll command with whitespace commandfor doesn't throw");
+
+ // Edge case: target element is disconnected
+ test(function (t) {
+ const disconnected = document.createElement("div");
+ disconnected.id = "disconnected";
+ disconnected.className = "scroll-container";
+ disconnected.innerHTML = '<div class="scroll-content"></div>';
+
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "disconnected");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw
+ assert_equals(button.click(), undefined, "Click should not throw for disconnected target");
+ }, "scroll command with disconnected target element doesn't throw");
+
+ // Edge case: target element is button itself
+ test(function (t) {
+ const button = document.createElement("button");
+ button.id = "selfbutton";
+ button.setAttribute("commandfor", "selfbutton");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw
+ assert_equals(button.click(), undefined, "Click should not throw when targeting self");
+ }, "scroll command targeting self doesn't throw");
+
+ // Edge case: target element is display:none
+ test(function (t) {
+ const hidden = document.createElement("div");
+ hidden.id = "hiddenscroll";
+ hidden.className = "scroll-container";
+ hidden.style.display = "none";
+ hidden.innerHTML = '<div class="scroll-content"></div>';
+ document.body.appendChild(hidden);
+ t.add_cleanup(() => hidden.remove());
+
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "hiddenscroll");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw
+ assert_equals(button.click(), undefined, "Click should not throw for hidden target");
+ assert_equals(hidden.scrollTop, 0, "Hidden element should not scroll");
+ }, "scroll command on display:none element does nothing");
+
+ // Edge case: target element has no computed style (e.g., in detached document)
+ test(function (t) {
+ const newDoc = document.implementation.createHTMLDocument();
+ const container = newDoc.createElement("div");
+ container.id = "detachedcontainer";
+ container.className = "scroll-container";
+ newDoc.body.appendChild(container);
+
+ // Add the container to main document so commandfor can find it
+ document.body.appendChild(container);
+ t.add_cleanup(() => container.remove());
+
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "detachedcontainer");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ // Should not throw
+ assert_equals(button.click(), undefined, "Click should not throw");
+ }, "scroll command handles elements with unusual document state");
+
+ // Edge case: button is disabled
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "scrollcontainer");
+ button.setAttribute("command", "page-down");
+ button.disabled = true;
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ const initialScrollTop = scrollcontainer.scrollTop;
+ button.click();
+ // Disabled buttons should not trigger commands
+ assert_equals(scrollcontainer.scrollTop, initialScrollTop, "Disabled button should not trigger scroll");
+ }, "disabled button doesn't trigger scroll command");
+
+ // Edge case: multiple buttons targeting same element
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const button1 = document.createElement("button");
+ button1.setAttribute("commandfor", "scrollcontainer");
+ button1.setAttribute("command", "page-down");
+ document.body.appendChild(button1);
+ t.add_cleanup(() => button1.remove());
+
+ const button2 = document.createElement("button");
+ button2.setAttribute("commandfor", "scrollcontainer");
+ button2.setAttribute("command", "page-down");
+ document.body.appendChild(button2);
+ t.add_cleanup(() => button2.remove());
+
+ const initialScrollTop = scrollcontainer.scrollTop;
+ button1.click();
+ const afterFirst = scrollcontainer.scrollTop;
+ assert_greater_than(afterFirst, initialScrollTop, "First button should scroll");
+
+ button2.click();
+ assert_greater_than(scrollcontainer.scrollTop, afterFirst, "Second button should also scroll");
+ }, "multiple buttons can target same scroll container");
+
+ // Edge case: scroll at boundary (can't scroll further up)
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ scrollcontainer.scrollTop = 0;
+
+ // Should not throw
+ pageup.click();
+ assert_equals(scrollcontainer.scrollTop, 0, "Should remain at top");
+ }, "scroll command at top boundary doesn't throw");
+
+ // Edge case: scroll at boundary (can't scroll further down)
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ scrollcontainer.scrollTop = scrollcontainer.scrollHeight - scrollcontainer.clientHeight;
+ const maxScroll = scrollcontainer.scrollTop;
+
+ // Should not throw
+ pagedown.click();
+ assert_equals(scrollcontainer.scrollTop, maxScroll, "Should remain at bottom");
+ }, "scroll command at bottom boundary doesn't throw");
+
+ // Edge case: invalid command value
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "scrollcontainer");
+ button.setAttribute("command", "invalid-scroll-command");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ const initialScrollTop = scrollcontainer.scrollTop;
+ button.click();
+ assert_equals(scrollcontainer.scrollTop, initialScrollTop, "Invalid command should not scroll");
+ }, "invalid scroll command value doesn't trigger scroll");
+
+ // Edge case: command attribute is empty
+ test(function (t) {
+ t.add_cleanup(() => resetScrollPosition(scrollcontainer));
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "scrollcontainer");
+ button.setAttribute("command", "");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ const initialScrollTop = scrollcontainer.scrollTop;
+ button.click();
+ assert_equals(scrollcontainer.scrollTop, initialScrollTop, "Empty command should not scroll");
+ }, "empty command attribute doesn't trigger scroll");
+
+ // Edge case: target has overflow:visible (not scrollable)
+ test(function (t) {
+ const visible = document.createElement("div");
+ visible.id = "visibleoverflow";
+ visible.style.width = "200px";
+ visible.style.height = "200px";
+ visible.style.overflow = "visible";
+ visible.innerHTML = '<div style="width: 1000px; height: 1000px;"></div>';
+ document.body.appendChild(visible);
+ t.add_cleanup(() => visible.remove());
+
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "visibleoverflow");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ button.click();
+ assert_equals(visible.scrollTop, 0, "overflow:visible element should not scroll");
+ }, "scroll command on overflow:visible element does nothing");
+
+ // Edge case: target has overflow:clip
+ test(function (t) {
+ const clipped = document.createElement("div");
+ clipped.id = "clippedoverflow";
+ clipped.style.width = "200px";
+ clipped.style.height = "200px";
+ clipped.style.overflow = "clip";
+ clipped.innerHTML = '<div style="width: 1000px; height: 1000px;"></div>';
+ document.body.appendChild(clipped);
+ t.add_cleanup(() => clipped.remove());
+
+ const button = document.createElement("button");
+ button.setAttribute("commandfor", "clippedoverflow");
+ button.setAttribute("command", "page-down");
+ document.body.appendChild(button);
+ t.add_cleanup(() => button.remove());
+
+ button.click();
+ assert_equals(clipped.scrollTop, 0, "overflow:clip element should not scroll");
+ }, "scroll command on overflow:clip element does nothing");
+</script>
+\ No newline at end of file