commit a915e2d4a2d2400e65d5c1b5aa17277d5b346d29 parent 6155f64fe370da9e504e24ada77462c6be1f4640 Author: David Awogbemila <awogbemila@chromium.org> Date: Thu, 11 Dec 2025 09:27:56 +0000 Bug 2004999 [wpt PR 56601] - [animation-trigger] Implement trigger-scope, a=testonly Automatic update from web-platform-tests [animation-trigger] Implement trigger-scope Bug: 390314945, 466134208 Change-Id: Ib1045aad44d9fe20930fc8b1c0663bcbcbb37e2a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7222830 Reviewed-by: David Awogbemila <awogbemila@chromium.org> Commit-Queue: David Awogbemila <awogbemila@chromium.org> Reviewed-by: Anders Hartvoll Ruud <andruud@chromium.org> Cr-Commit-Position: refs/heads/main@{#1556161} -- wpt-commits: e16949196757b0c4931589ea8b9a910680095528 wpt-pr: 56601 Diffstat:
7 files changed, 659 insertions(+), 0 deletions(-)
diff --git a/testing/web-platform/tests/scroll-animations/animation-trigger/support/trigger-scope-support.js b/testing/web-platform/tests/scroll-animations/animation-trigger/support/trigger-scope-support.js @@ -0,0 +1,19 @@ + +// Used in trigger-scope-* tests to check whether an animation was triggered or +// not. +async function assert_playstate_and_current_time(target_id, animation, play_state) { + // The animation might start on a different user-agent thread and need + // a moment to get currentTime up to date. + await waitForCompositorReady(); + + assert_equals(animation.playState, play_state, + `animation on ${target_id} is ${play_state}.`); + + if (play_state === "running") { + assert_greater_than(animation.currentTime, 0, + `animation on ${target_id} has currentTime > 0.`); + } else { + assert_equals(animation.currentTime, 0, + `animation on ${target_id} has currentTime == 0.`); + } +} diff --git a/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-in-scope-source.tentative.html b/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-in-scope-source.tentative.html @@ -0,0 +1,98 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="help" src="https://drafts.csswg.org/css-animations-2/#trigger-scope"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/web-animations/testcommon.js"></script> + <script src="/dom/events/scrolling/scroll_support.js"></script> + <script src="support/support.js"></script> + <script src="support/trigger-scope-support.js"></script> + </head> + <body> + <style> + @keyframes expand { + from { transform: scaleX(1) } + to { transform: scaleX(2) } + } + #scroller { + overflow-y: scroll; + height: 200px; + width: 200px; + border: solid 1px; + trigger-scope: all; + display: block; + } + #source { + top: 100%; + height: 100px; + width: 100px; + background-color: blue; + timeline-trigger: --trigger view() contain; + } + .target { + background-color: red; + height: 100px; + width: 100px; + animation: expand linear 1s both; + animation-trigger: --trigger play-forwards play-backwards; + } + + #in_scope_target { + /* Let's it be in view when the trigger source comes into view */ + margin-top: 50%; + } + .long { + width: 50%; + height: 100%; + } + </style> + <div id="scroller"> + <div id="in_scope_target" class="target">In-scope Target</div> + <div id="source">SOURCE</div> + <div class="long"></div> + <div class="long"></div> + </div> + <div id="out_of_scope_target" class="target"> + Out-of-scope Target + </div> + + <script> + promise_test(async() => { + const in_scope_target = document.getElementById("in_scope_target"); + // The in-scope target should be attached to the trigger and paused at + // time 0. + await assert_playstate_and_current_time(in_scope_target.id, + in_scope_target.getAnimations()[0], + "paused"); + + // The out-of-scope target should be paused at time 0, waiting for its + // trigger. + await assert_playstate_and_current_time(out_of_scope_target.id, + out_of_scope_target.getAnimations()[0], + "paused"); + + // Perform a scroll that triggers the animation. + const scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(scroller); + source.scrollIntoView({block: "center"}); + await scrollend_promise; + await waitForCompositorReady(); + + assert_greater_than(scroller.scrollTop, 0, "did scroll"); + + // The in-scope target should now be playing as the trigger condition + // has been met. + await assert_playstate_and_current_time(in_scope_target.id, + in_scope_target.getAnimations()[0], + "running"); + + // There should be no change to the out-of-scope target. + await assert_playstate_and_current_time(out_of_scope_target.id, + out_of_scope_target.getAnimations()[0], + "paused"); + }, "In-scope target finds trigger of source; out-of-scope " + + "target does not."); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-named.tentative.html b/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-named.tentative.html @@ -0,0 +1,96 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="help" src="https://drafts.csswg.org/css-animations-2/#trigger-scope"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/web-animations/testcommon.js"></script> + <script src="/dom/events/scrolling/scroll_support.js"></script> + <script src="support/support.js"></script> + <script src="support/trigger-scope-support.js"></script> + </head> + <body> + <style> + @keyframes expand { + from { transform: scaleX(1) } + to { transform: scaleX(2) } + } + #scroller { + overflow-y: scroll; + height: 200px; + width: 200px; + border: solid 1px; + trigger-scope: --trigger; + display: block; + } + #source { + top: 100%; + height: 100px; + width: 100px; + background-color: blue; + timeline-trigger: --trigger view() contain; + } + .target { + background-color: red; + height: 100px; + width: 100px; + animation: expand linear 1s both; + animation-trigger: --trigger play-forwards play-backwards; + } + + #in_scope_target { + /* Let's it be in view when the trigger source comes into view */ + margin-top: 50%; + } + + .long { + width: 50%; + height: 100%; + } + </style> + <div id="scroller"> + <div id="in_scope_target" class="target">In-scope Target</div> + <div id="source">SOURCE</div> + <div class="long"></div> + <div class="long"></div> + </div> + <div id="out_of_scope_target" class="target"> + Out-of-scope Target + </div> + <script> + promise_test(async() => { + const in_scope_target = document.getElementById("in_scope_target"); + // The in-scope targets should be attached to the trigger and paused at + // time 0. + await assert_playstate_and_current_time( + in_scope_target.id, in_scope_target.getAnimations()[0], "paused"); + + // The out-of-scope targets should be attached to the trigger and paused + // are yet to be paused by the trigger. + await assert_playstate_and_current_time( + out_of_scope_target.id, out_of_scope_target.getAnimations()[0], + "paused"); + + // Perform a scroll to trigger the animation. + const scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(scroller); + source.scrollIntoView({block: "center"}); + await scrollend_promise; + await waitForCompositorReady(); + + assert_greater_than(scroller.scrollTop, 0, "did scroll"); + + // The in-scope target should now be playing as the trigger condition + // has been met. + await assert_playstate_and_current_time( + in_scope_target.id, in_scope_target.getAnimations()[0], "running"); + + // There should be no change to the out-of-scope targets. + await assert_playstate_and_current_time(out_of_scope_target.id, + out_of_scope_target.getAnimations()[0], + "paused"); + }, "target within named trigger-scope finds trigger within scope; "+ + "out-of-scope target does not."); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-oof.tentative.html b/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-oof.tentative.html @@ -0,0 +1,139 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="help" src="https://drafts.csswg.org/css-animations-2/#trigger-scope"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/web-animations/testcommon.js"></script> + <script src="/dom/events/scrolling/scroll_support.js"></script> + <script src="support/support.js"></script> + <script src="support/trigger-scope-support.js"></script> + </head> + <body> + <style> + @keyframes expand { + from { transform: scaleX(1) } + to { transform: scaleX(2) } + } + #outerscroller { + position: relative; + overflow-y: scroll; + border: solid 1px; + height: 400px; + width: 400px; + } + #innerscroller { + overflow-y: scroll; + height: 200px; + width: 200px; + border: solid 1px; + trigger-scope: all; + display: block; + } + #source { + position: absolute; + top: 100%; + height: 100px; + width: 100px; + background-color: blue; + timeline-trigger: --trigger view() contain; + } + .target { + background-color: red; + height: 100px; + width: 100px; + animation: expand linear 1s both; + animation-trigger: --trigger play-forwards play-backwards; + } + + #in_scope_target { + /* Let's it be in view when the trigger source comes into view */ + margin-top: 50%; + } + #in_scope_oof_target { + position: fixed; + left: 50vw; + } + #out_of_scope_oof_target { + position: fixed; + left: 50vw; + top: 50vh; + } + + .long { + width: 50%; + height: 100%; + } + </style> + <div id="outerscroller"> + <div id="innerscroller"> + <div id="in_scope_target" class="target">In-scope In-Flow Target</div> + <div id="in_scope_oof_target" class="target">In-scope OOF Target</div> + <div class="long"></div> + <div class="long"></div> + <div id="source">SOURCE</div> + <div class="long"></div> + <div class="long"></div> + </div> + <div id="out_of_scope_target" class="target"> + Out-of-scope In-Flow False Target + </div> + <div id="out_of_scope_oof_target" class="target"> + Out-of-scope OOF False Target + </div> + <div class="long"></div> + <div class="long"></div> + </div> + + <script> + const in_scope_targets = [in_scope_target, in_scope_oof_target]; + const out_of_scope_targets = + [out_of_scope_target, out_of_scope_oof_target]; + + promise_test(async() => { + for (const target of in_scope_targets) { + assert_equals( target.getAnimations().length, 1); + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "paused"); + } + + // The out-of-scope targets should be attached to the trigger and paused + // are yet to be paused by the trigger. + for (const target of out_of_scope_targets) { + assert_equals( target.getAnimations().length, 1); + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "paused"); + } + + // Perform a scroll to trigger the animation. + const scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(outerscroller); + source.scrollIntoView({block: "center"}); + await scrollend_promise; + await waitForCompositorReady(); + + assert_greater_than(outerscroller.scrollTop, 0, "did scroll"); + + // The in-scope targets should now be playing as the trigger condition + // has been met. + for (const target of in_scope_targets) { + assert_equals( target.getAnimations().length, 1); + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "running"); + } + + // There should be no change to the out-of-scope targets. + for (const target of out_of_scope_targets) { + assert_equals( target.getAnimations().length, 1); + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "paused"); + } + }, "In-scope targets find trigger of oof source; out-of-scope " + + "targets do not."); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-out-of-scope-source.tentative.html b/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-out-of-scope-source.tentative.html @@ -0,0 +1,106 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="help" src="https://drafts.csswg.org/css-animations-2/#trigger-scope"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/web-animations/testcommon.js"></script> + <script src="/dom/events/scrolling/scroll_support.js"></script> + <script src="support/support.js"></script> + <script src="support/trigger-scope-support.js"></script> + </head> + <body> + <style> + @keyframes expand { + from { transform: scaleX(1) } + to { transform: scaleX(2) } + } + #outerscroller { + position: relative; + overflow-y: scroll; + border: solid 1px; + height: 400px; + width: 400px; + } + #innerscroller { + overflow-y: scroll; + height: 200px; + width: 200px; + border: solid 1px; + trigger-scope: all; + display: block; + } + #source { + position: absolute; + top: 100%; + height: 100px; + width: 100px; + background-color: blue; + timeline-trigger: --trigger view() contain; + } + .target { + background-color: red; + height: 100px; + width: 100px; + animation: expand linear 1s both; + animation-trigger: --trigger play-forwards play-backwards; + } + + #in_scope_target { + /* Let's it be in view when the trigger source comes into view */ + margin-top: 50%; + } + + .long { + width: 50%; + height: 100%; + } + </style> + <div id="outerscroller"> + <div id="innerscroller"> + <div id="in_scope_target" class="target">In-scope In-Flow Target</div> + <div class="long"></div> + <div class="long"></div> + </div> + <div id="source">SOURCE</div> + <div id="out_of_scope_target" class="target"> + Out-of-scope In-Flow False Target + </div> + <div class="long"></div> + <div class="long"></div> + </div> + + <script> + promise_test(async() => { + await assert_playstate_and_current_time( + in_scope_target.id, in_scope_target.getAnimations()[0], "paused"); + + // The out-of-scope target should be attached to the trigger and + // paused at time 0. + await assert_playstate_and_current_time(out_of_scope_target.id, + out_of_scope_target.getAnimations()[0], + "paused"); + + const scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(outerscroller); + source.scrollIntoView({block: "center"}); + await scrollend_promise; + + await waitForCompositorReady(); + + assert_greater_than(outerscroller.scrollTop, 0, "did scroll"); + + // There should be no change to the in-scope target. + await assert_playstate_and_current_time(in_scope_target.id, + in_scope_target.getAnimations()[0], + "paused"); + // The out-of-scope target should now be playing as the trigger + // condition has been met. + await assert_playstate_and_current_time(out_of_scope_target.id, + out_of_scope_target.getAnimations()[0], + "running"); + }, "trigger-scope prevents in-scope target from finding trigger " + + "outside scope; target outside trigger-scope finds trigger"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-scoped-tree-order.tentative.html b/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-scoped-tree-order.tentative.html @@ -0,0 +1,103 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="help" src="https://drafts.csswg.org/css-animations-2/#trigger-scope"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/web-animations/testcommon.js"></script> + <script src="/dom/events/scrolling/scroll_support.js"></script> + <script src="support/support.js"></script> + <script src="support/trigger-scope-support.js"></script> + </head> + <body> + <style> + @keyframes expand { + from { transform: scaleX(1) } + to { transform: scaleX(2) } + } + #scroller { + overflow-y: scroll; + height: 200px; + width: 200px; + border: solid 1px; + trigger-scope: all; + display: block; + position: relative; + } + .source { + top: 100%; + height: 100px; + width: 100px; + background-color: blue; + timeline-trigger: --trigger view() contain; + } + .target { + background-color: red; + height: 100px; + width: 100px; + animation: expand linear 1s both; + animation-trigger: --trigger play-forwards play-backwards; + position: sticky; + top: 0%; + left: 50%; + } + + .long { + width: 50%; + height: 100%; + } + </style> + <div id="scroller"> + <div id="target" class="target">Target</div> + <div class="long"></div> + <div id="source1" class="source">SOURCE 1</div> + <div class="long"></div> + <div id="source2" class="source">SOURCE 2</div> + <div class="long"></div> + <div id="source3" class="source">SOURCE 3</div> + <div class="long"></div> + <div id="source4" class="source">SOURCE 4</div> + <div class="long"></div> + <div id="source5" class="source">SOURCE 5</div> + <div class="long"></div> + </div> + <script> + + promise_test(async() => { + // The in-scope targets should be attached to the trigger and paused at + // time 0. + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "paused"); + let scrollend_promise; + + // Sources 1 through 4 come earlier than source5 in tree-order so they + // should have no effect. + for (const source of [source1, source2, source3, source4]) { + scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(scroller); + source.scrollIntoView({block: "center"}); + await scrollend_promise; + await waitForCompositorReady(); + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "paused"); + } + + scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(scroller); + source5.scrollIntoView({block: "center"}); + await scrollend_promise; + await waitForCompositorReady(); + + await assert_greater_than(scroller.scrollTop, 0, "did scroll"); + // The in-scope targets should now be playing as the trigger condition + // has been met. + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "running"); + }, "Among in-scope triggers with same name, last in tree-order is "+ + "selected."); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-unscoped-tree-order.tentative.html b/testing/web-platform/tests/scroll-animations/animation-trigger/trigger-scope-unscoped-tree-order.tentative.html @@ -0,0 +1,98 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="help" src="https://drafts.csswg.org/css-animations-2/#trigger-scope"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/web-animations/testcommon.js"></script> + <script src="/dom/events/scrolling/scroll_support.js"></script> + <script src="support/support.js"></script> + <script src="support/trigger-scope-support.js"></script> + </head> + <body> + <style> + @keyframes expand { + from { transform: scaleX(1) } + to { transform: scaleX(2) } + } + #scroller { + overflow-y: scroll; + height: 200px; + width: 200px; + border: solid 1px; + display: block; + } + .source { + top: 100%; + height: 100px; + width: 100px; + background-color: blue; + timeline-trigger: --trigger view() contain; + } + .target { + background-color: red; + height: 100px; + width: 100px; + animation: expand linear 1s both; + animation-trigger: --trigger play-forwards play-backwards; + } + + .long { + width: 50%; + height: 100%; + } + </style> + <div id="scroller"> + <div class="long"></div> + <div id="source1" class="source">SOURCE 1</div> + <div class="long"></div> + <div id="source2" class="source">SOURCE 2</div> + <div class="long"></div> + <div id="source3" class="source">SOURCE 3</div> + <div class="long"></div> + <div id="source4" class="source">SOURCE 4</div> + <div class="long"></div> + <div id="source5" class="source">SOURCE 5</div> + <div class="long"></div> + </div> + <div id="target" class="target">Target</div> + <script> + + promise_test(async() => { + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "paused"); + + let scrollend_promise; + + // Sources 1 through 4 come earlier than source5 in tree-order so they + // should have no effect. + for (const source of [source1, source2, source3, source4]) { + scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(scroller); + source.scrollIntoView({block: "center"}); + await scrollend_promise; + await waitForCompositorReady(); + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "paused"); + } + + scrollend_promise = + waitForScrollEndFallbackToDelayWithoutScrollEvent(scroller); + source5.scrollIntoView({block: "center"}); + await scrollend_promise; + await waitForCompositorReady(); + + assert_greater_than(scroller.scrollTop, 0, "did scroll"); + + // The target should now be playing as the trigger condition + // has been met. + await assert_playstate_and_current_time(target.id, + target.getAnimations()[0], + "running"); + }, "Among un-scoped triggers with same name, last in tree-order is "+ + "selected."); + </script> + </body> +</html>