summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/scroll-animations/view-timelines
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /testing/web-platform/tests/scroll-animations/view-timelines
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/scroll-animations/view-timelines')
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html83
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html101
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html207
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html113
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html88
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html112
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html111
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html203
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html50
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html302
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html63
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html153
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html120
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html121
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html121
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html120
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html121
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html127
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html128
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html14
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html47
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html48
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js146
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html264
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html59
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html148
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html127
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html226
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html54
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html59
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html105
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html198
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html41
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html58
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html94
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html94
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html90
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html81
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html106
40 files changed, 4548 insertions, 0 deletions
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html b/testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html
new file mode 100644
index 0000000000..b456794225
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#events">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow: auto;
+ height: 200px;
+ width: 200px;
+ }
+ .spacer {
+ height: 400px;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ const keyframes = {transform: ['translateX(0)', 'translateX(100px)']};
+ let target = document.getElementById('target');
+ let scroller = document.querySelector('#container');
+ let timeline = new ViewTimeline({subject: target});
+ promise_test(async t => {
+ let animation = target.animate(keyframes, {
+ timeline,
+ fill: 'both'
+ });
+ scroller.scrollTo({top: 0});
+ await waitForCompositorReady();
+ let finishedPromise = animation.finished;
+ let finished = false;
+ let finishEvents = 0;
+ finishedPromise.then(() => {
+ finished = true;
+ });
+ animation.addEventListener('finish', () => { finishEvents++; });
+
+ scroller.scrollTo({top: 100});
+ await waitForNextFrame();
+ assert_false(finished, "Animation is not finished before starting");
+ assert_equals(finishEvents, 0, "No finish event before scrolling");
+
+ scroller.scrollTo({top: 400});
+ await waitForNextFrame();
+ assert_false(finished, "Animation is not finished while active");
+ assert_equals(finishEvents, 0, "No finish event while active");
+
+ scroller.scrollTo({top: 600});
+ await waitForNextFrame();
+ assert_true(finished, "Animation is finished after passing end");
+ assert_equals(finishEvents, 1, "A finish event is generated after end");
+
+ scroller.scrollTo({top: 400});
+ await waitForNextFrame();
+ assert_not_equals(finishedPromise, animation.finished,
+ "A new finish promise is created when back in active range");
+ finished = false;
+ animation.finished.then(() => {
+ finished = true;
+ });
+
+ scroller.scrollTo({top: 600});
+ await waitForNextFrame();
+ assert_true(finished, "Finishes after passing end");
+ assert_equals(finishEvents, 2, "Another finish event is generated after end");
+ animation.cancel();
+ }, 'View timeline generates and resolves finish promises and events' );
+
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html
new file mode 100644
index 0000000000..beb380060e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html
@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline current-time with vertical-rl writing mode</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ writing-mode: vertical-rl;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ .spacer {
+ width: 800px;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 200px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target, {axis: 'block'});
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollLeft = -600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollLeft = -800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollLeft = -1000;
+ anim.effect.updateTiming({ fill: 'forwards' });
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '0.7',
+ 'Opacity with fill forwards at effect end time');
+ anim.effect.updateTiming({ fill: 'none' });
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Opacity with fill none at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollLeft = -1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with container having vertical-rl layout' );
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html
new file mode 100644
index 0000000000..c24d04412f
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html
@@ -0,0 +1,207 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline current-time</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ .spacer {
+ height: 800px;
+ }
+ #target {
+ background-color: green;
+ height: 200px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollTop = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollTop = 1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with start and end scroll offsets that do not align with ' +
+ 'the scroll boundaries' );
+
+ promise_test(async t => {
+ const leading = document.getElementById('leading-space');
+ leading.style = 'display: none';
+ t.add_cleanup(() => {
+ leading.style = null;
+ });
+
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect enters active phase at container start boundary');
+
+
+ // Advance to midpoint
+ container.scrollTop = 100;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 75,
+ "Timeline's current time at midpoint");
+ assert_percents_equal(anim.currentTime, 75,
+ "Animation's current time at midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.6',
+ 'Effect at the middle of the active phase');
+
+ // Advance to end-offset
+ container.scrollTop = 200;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's current time at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect inactive at the end offset');
+
+ // Advance to scroll limit.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's current time at scroll limit");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at scroll limit");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect inactive in the after phase');
+
+ }, 'View timeline does not clamp starting scroll offset at 0');
+
+ promise_test(async t => {
+ const trailing = document.getElementById('trailing-space');
+ trailing.style = 'display: none';
+ t.add_cleanup(() => {
+ trailing.style = null;
+ });
+
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially in before phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect enters active phase at container start boundary');
+
+ // Advance to start offset.
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to midpoint.
+ container.scrollTop = 700;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 25,
+ "Timeline's current time at the midpoint");
+ assert_percents_equal(anim.currentTime, 25,
+ "Animation's current time at the midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.4',
+ 'Effect at the midpoint of the active phase');
+
+ // Advance to end offset.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at max scroll offset");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at max scroll offset");
+ // The active-after boundary is inclusive since at the maximum scroll
+ // position.
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect at end of active phase');
+ }, 'View timeline does not clamp end scroll offset at max scroll');
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html
new file mode 100644
index 0000000000..6fdc7c6822
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline nested subject</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style type="text/css">
+ #container {
+ overflow-y: scroll;
+ height: 300px;
+ width: 300px;
+ }
+ .big-spacer {
+ height: 800px;
+ }
+ .small-spacer {
+ height: 100px;
+ }
+ #block {
+ background-color: #ddd;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div class="big-spacer"></div>
+ <div id="block">
+ <div class="small-spacer"></div>
+ <div id="target"></div>
+ </div>
+ <div class="big-spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // start offset = 800 + 100 - 300 = 600
+ // end offset = 800 + 100 + 100 = 1000
+ // scroll limit = L = 800 + 200 + 800 - 300 = 1500
+ // progress = P = (current - start) / (end - start)
+ // P(0) = -600 / 400 = -1.5
+ // P(L) = 900 / 400 = 2.5
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollTop = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollTop = 1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 225,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with subject that is not a direct descendant of the ' +
+ 'scroll container');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html b/testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html
new file mode 100644
index 0000000000..ee01070a53
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<title>Animation range updates play state</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { background-color: blue; }
+ to { background-color: white; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-top: 800px;
+ margin-bottom: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ animation-timeline: --t1;
+ view-timeline: --t1;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ promise_test(async t => {
+ anim = target.getAnimations()[0];
+ await anim.ready;
+
+ // Cover range = 600px to 900px
+
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+
+ // Animation is running in the active phase.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = 'contain 0%'; // 700px
+ anim.rangeEnd = 'contain 100%'; // 800px
+ });
+ assert_equals(anim.playState, 'running');
+ assert_percents_equal(anim.startTime, 100/3);
+ assert_percents_equal(anim.currentTime, 100/6);
+
+ // Animation in the after phase and switches to the finished state.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = 'entry 0%'; // 600px
+ anim.rangeEnd = 'entry 100%'; // 700px
+ });
+ assert_equals(anim.playState, 'finished');
+ assert_percents_equal(anim.startTime, 0);
+ // In the after phase, so current time is clamped.
+ assert_percents_equal(anim.currentTime, 100/3);
+
+ // Animation in the before phase and switches back to the running state.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = 'exit 0%'; // 800px
+ anim.rangeEnd = 'exit 100%'; // 900px
+ });
+ assert_equals(anim.playState, 'running');
+ assert_percents_equal(anim.startTime, 200/3);
+ assert_percents_equal(anim.currentTime, -100/6);
+
+ }, 'Changing the animation range updates the play state');
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html b/testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html
new file mode 100644
index 0000000000..8b61a9ab81
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html
@@ -0,0 +1,112 @@
+<!DOCTYPE html>
+<html>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/">
+<style>
+
+@keyframes bg {
+ from { background-color: rgb(254, 0, 0); }
+ to { background-color: rgb(0 254, 0); }
+}
+.item {
+ flex-grow: 1;
+ width: 2em;
+ height: 2em;
+ background: #888;
+ animation: bg linear;
+ animation-timeline: view();
+ animation-range: contain;
+}
+
+.inline .item {
+ animation-timeline: view(inline);
+}
+
+.scroller {
+ width: 10em;
+ height: 10em;
+ outline: 1px solid;
+ margin: 1em;
+ overflow: auto;
+ display: inline-flex;
+ vertical-align: top;
+ flex-direction: column;
+ gap: 1em;
+ resize: vertical;
+}
+
+.inline {
+ resize: horizontal;
+ flex-direction: row;
+}
+
+.block .spacer {
+ height: 20em;
+ width: 1em;
+}
+
+.inline .spacer {
+ width: 20em;
+ height: 1em;
+}
+</style>
+<body>
+<div class="scroller block">
+ <div class="item" id="a"></div>
+ <div class="item" id="b"></div>
+ <div class="item" id="c"></div>
+</div>
+
+<div class="scroller inline">
+ <div class="item" id="d"></div>
+ <div class="item" id="e"></div>
+ <div class="item" id="f"></div>
+</div>
+
+<br>
+
+<div class="scroller block">
+ <div class="item" id="g"></div>
+ <div class="item" id="h"></div>
+</div>
+
+<div class="scroller inline">
+ <div class="item" id="i"></div>
+ <div class="item" id="j"></div>
+</div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script type="text/javascript">
+ promise_test(async t => {
+ let anims = document.getAnimations();
+ await Promise.all(anims.map(anim => anim.ready));
+ await waitForNextFrame();
+
+ const expected_results = [
+ { id: "a", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "b", progress: 0.5, bg: 'rgb(127, 127, 0)' },
+ { id: "c", progress: 0.0, bg: 'rgb(254, 0, 0)' },
+ { id: "d", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "e", progress: 0.5, bg: 'rgb(127, 127, 0)' },
+ { id: "f", progress: 0.0, bg: 'rgb(254, 0, 0)' },
+ { id: "g", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "h", progress: 0.0, bg: 'rgb(254, 0, 0)' },
+ { id: "i", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "j", progress: 0.0, bg: 'rgb(254, 0, 0)' }
+ ];
+
+ expected_results.forEach(result => {
+ const element = document.getElementById(result.id);
+ const anim = element.getAnimations()[0];
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ result.progress, 1e-3,
+ `${result.id}: Unexpected progress`);
+ assert_equals(getComputedStyle(element).backgroundColor,
+ result.bg, `${result.id}: Mismatched background color`);
+ });
+
+ }, 'Stability of animated elements aligned to the bounds of a contain region');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html b/testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html
new file mode 100644
index 0000000000..d75f30e664
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>View timeline with fieldset as source</title>
+<link rel="help" href="https://www.w3.org/TR/scroll-animations-1/#dom-viewtimeline-viewtimeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ @keyframes colorize {
+ from { background-color: #ccf; }
+ to { background-color: white; }
+ }
+
+ .input {
+ background-color: white;
+ view-timeline: --timeline;
+ animation: colorize;
+ animation-timeline: --timeline;
+ margin-top: 0px;
+ margin-bottom: 3px;
+ margin-left: 8px;
+ height: 20px;
+ width: 150px;
+ }
+
+ .input:last-child {
+ margin-bottom: 0px;
+ }
+
+ fieldset {
+ display: inline-block;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ height: 80px;
+ }
+
+ div {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ }
+</style>
+<body>
+ <fieldset id="fieldset">
+ <legend id="legend">Reservation Details</legend>
+ <div>
+ <label for="name">Name: </label>
+ <input type="text" class="input" id="input1" value="Jane Doe" />
+ </div>
+ <div>
+ <label for="date">Date: </label>
+ <input type="date" class="input" id="input2" value="2024-01-16"/>
+ </div>
+ <div>
+ <label for="time">Time: </label>
+ <input type="time" class="input" id="input3" value="18:30"/>
+ </div>
+ <div>
+ <label for="name">Number of guests: </label>
+ <input type="number" class="input" id="input4" value="5" />
+ </div>
+ <div>
+ <label for="name">Contact info: </label>
+ <input type="text" class="input" id="input5" value="(555) 555-5555" />
+ </div>
+ </fieldset>
+</body>
+<script>
+ async function runTest() {
+ promise_test(async t => {
+ const anims = document.getAnimations();
+ assert_equals(anims.length, 5);
+ await Promise.all(anims.map(anim => anim.ready));
+
+ // The bottom of the legend aligns with the top of the fieldset's
+ // scrollable area.
+ const fieldset = document.getElementById('fieldset');
+ const legend = document.getElementById('legend');
+ const fieldsetContentTop =
+ legend.getBoundingClientRect().bottom;
+
+ // The bottom of the scroll container aligns with the bottom of the
+ // fieldset's content box.
+ const fieldsetContentBottom =
+ fieldset.getBoundingClientRect().bottom -
+ parseFloat(getComputedStyle(fieldset).borderBottom);
+
+ // Validate the start and end offsets for each view timeline.
+ anims.forEach(async (anim) => {
+ assert_equals(anim.timeline.source.id, 'fieldset');
+ assert_equals(anim.timeline.subject.tagName, 'INPUT');
+ const bounds = anim.effect.target.getBoundingClientRect();
+
+ const expectedStartOffset = bounds.top - fieldsetContentBottom;
+ const expectedEndOffset = bounds.bottom - fieldsetContentTop;
+ assert_approx_equals(
+ parseFloat(anim.timeline.startOffset),
+ expectedStartOffset, 0.1,
+ `Unexpected start offset for ${anim.effect.target.id}`);
+ assert_approx_equals(
+ parseFloat(anim.timeline.endOffset),
+ expectedEndOffset, 0.1,
+ `Unexpected end offset for ${anim.effect.target.id}`);
+ });
+ }, 'Fieldset is a valid source for a view timeline');
+ }
+
+ window.onload = runTest();
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html b/testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html
new file mode 100644
index 0000000000..02f910d04e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html
@@ -0,0 +1,203 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<!-- TODO(kevers): Insert link once resolutions present in spec -->
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Reported keyframes containing timeline offset</title>
+</head>
+<style type="text/css">
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin: 800px 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function createAnimation(t, keyframes, use_view_timeline = true) {
+ const options = {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto',
+ fill: 'both'
+ };
+ if (use_view_timeline) {
+ options.timeline = new ViewTimeline( { subject: target });
+ }
+ const anim = target.animate(keyframes, options);
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ return anim;
+ }
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "contain 25%", marginLeft: "0px", opacity: "0" },
+ { offset: "contain 75%", marginRight: "0px", opacity: "1" }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'contain', offset: CSS.percent(25) },
+ computedOffset: 0.25, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'contain', offset: CSS.percent(75) },
+ computedOffset: 0.75, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Report specified timeline offsets');
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "cover 0%", marginLeft: "0px", opacity: "0" },
+ { offset: "cover 100%", marginRight: "0px", opacity: "1" }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Computed offsets can be outside [0,1] for keyframes with timeline ' +
+ 'offsets');
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "contain 75%", marginLeft: "0px", opacity: "0" },
+ { offset: "contain 25%", marginRight: "0px", opacity: "1" }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'contain', offset: CSS.percent(75) },
+ computedOffset: 0.75, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'contain', offset: CSS.percent(25) },
+ computedOffset: 0.25, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Retain specified ordering of keyframes with timeline offsets');
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "cover 0%", marginLeft: "0px", opacity: "0" },
+ { offset: "cover 100%", marginRight: "0px", opacity: "1" }
+ ], /* use_view_timeline */ false);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Include unreachable keyframes');
+
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "cover 0%", marginLeft: "0px", opacity: 0 },
+ { offset: "cover 100%", marginRight: "0px", opacity: 1 },
+ { opacity: 0 },
+ { opacity: 0.5 },
+ { opacity: 1.0 }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ { offset: null, computedOffset: 0, easing: "linear", composite: "auto",
+ opacity: "0" },
+ { offset: null, computedOffset: 0.5, easing: "linear",
+ composite: "auto", opacity: "0.5" },
+ { offset: null, computedOffset: 1.0, easing: "linear",
+ composite: "auto", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+
+ anim = createAnimation(t, [
+ { opacity: 0 },
+ { offset: "cover 0%", marginLeft: "0px", opacity: 0 },
+ { opacity: 0.5 },
+ { offset: "cover 100%", marginRight: "0px", opacity: 1 },
+ { opacity: 1.0 }
+ ]);
+ frames = anim.effect.getKeyframes();
+ expected = [
+ { offset: null, computedOffset: 0, easing: "linear", composite: "auto",
+ opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: null, computedOffset: 0.5, easing: "linear",
+ composite: "auto", opacity: "0.5" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ { offset: null, computedOffset: 1.0, easing: "linear",
+ composite: "auto", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+
+ anim = createAnimation(t, [
+ { opacity: 0.2, offset: 0.2 },
+ { offset: "cover 0%", marginLeft: "0px", opacity: 0 },
+ { opacity: 0.4 },
+ { opacity: 0.6 },
+ { offset: "cover 100%", marginRight: "0px", opacity: 1 },
+ { opacity: 0.8, offset: 0.8 }
+ ]);
+ frames = anim.effect.getKeyframes();
+ expected = [
+ { offset: 0.2, computedOffset: 0.2, easing: "linear", composite: "auto",
+ opacity: "0.2" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: null, computedOffset: 0.4, easing: "linear",
+ composite: "auto", opacity: "0.4" },
+ { offset: null, computedOffset: 0.6, easing: "linear",
+ composite: "auto", opacity: "0.6" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ { offset: 0.8, computedOffset: 0.8, easing: "linear", composite: "auto",
+ opacity: "0.8" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Mix of computed and timeline offsets.');
+ }
+
+ window.onload = runTest;
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html b/testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html
new file mode 100644
index 0000000000..6b1d216dea
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element</title>
+</head>
+<style type="text/css">
+ @keyframes bg {
+ from { background-color: blue; }
+ to { background-color: green; }
+ }
+
+ #colorize {
+ animation: bg steps(2, jump-none) both;
+ animation-timeline: view();
+ animation-range: contain;
+ background-color: red;
+ color: white;
+ }
+
+ .spacer {
+ height: 80vh;
+ }
+</style>
+<body>
+<div class="spacer"></div>
+<div id="content">
+ <p>Hello <span id="colorize">world</span></p>
+</div>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(anim.effect.target)
+ .backgroundColor, 'rgb(0, 0, 255)');
+ scroller.scrollTop =
+ scroller.scrollHeight - scroller.clientHeight;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(anim.effect.target)
+ .backgroundColor, 'rgb(0, 128, 0)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html
new file mode 100644
index 0000000000..59d73d0cdf
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html
@@ -0,0 +1,302 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline current-time</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 200px;
+ display: inline-block;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollLeft = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollLeft = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollLeft = 1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with start and end scroll offsets that do not align with ' +
+ 'the scroll boundaries' );
+
+ promise_test(async t => {
+ const leading = document.getElementById('leading-space');
+ leading.style = 'display: none';
+ content.style = 'width: 1000px';
+ t.add_cleanup(() => {
+ leading.style = null;
+ content.style = null;
+ });
+
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect enters active phase at container start boundary');
+
+
+ // Advance to midpoint
+ container.scrollLeft = 100;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 75,
+ "Timeline's current time at the midpoint");
+ assert_percents_equal(anim.currentTime, 75,
+ "Animation's current time at the midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.6',
+ 'Effect at the midpoint of the active phase');
+
+ // Advance to end-offset
+ container.scrollLeft = 200;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's current time at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect at the end of the active phase');
+
+ // Advance to scroll limit.
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's current time at the scroll limit");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at the scroll limit");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect at the scroll limit');
+
+ }, 'View timeline does not clamp starting scroll offset at 0');
+
+ promise_test(async t => {
+ const trailing = document.getElementById('trailing-space');
+ trailing.style = 'display: none';
+ content.style = 'width: 1000px';
+ t.add_cleanup(() => {
+ trailing.style = null;
+ content.style = null;
+ });
+
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially in before phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect enters active phase at container start boundary');
+
+ // Advance to start offset.
+ container.scrollLeft = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to midpoint
+ container.scrollLeft = 700;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 25,
+ "Timeline's current time at midpoint");
+ assert_percents_equal(anim.currentTime, 25,
+ "Animation's current time at midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.4',
+ 'Effect at the midpoint of the active phase');
+
+ // Advance to end offset.
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at max scroll offset");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at max scroll offset");
+ // The active-after boundary is inclusive since at the scroll-limit.
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect at end of active phase');
+ }, 'View timeline does not clamp end scroll offset at max scroll');
+
+
+ promise_test(async t => {
+ container.style = "direction: rtl";
+ container.scrollLeft = 0;
+ t.add_cleanup(() => {
+ content.style = null;
+ });
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollLeft = -600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollLeft = -800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollLeft = -1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollLeft = -1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with container having RTL layout' );
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html
new file mode 100644
index 0000000000..057d0afabc
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title></title>
+</head>
+<style type="text/css">
+ .scroller {
+ display: inline-block;
+ border: 2px solid black;
+ height: 100px;
+ width: 100px;
+ overflow: hidden;
+ }
+ .box {
+ background: gray;
+ height: 50px;
+ width: 50px;
+ margin: 0;
+ }
+ .half-shift {
+ transform: translateX(25px);
+ }
+ .full-shift {
+ transform: translateX(50px);
+ }
+ .blue {
+ background-color: #99f;
+ }
+ .green {
+ background-color: #9f9;
+ }
+</style>
+<body>
+ <div id="scroller-1" class="scroller">
+ <div class="box green"></div>
+ <div class="box blue full-shift"></div>
+ </div>
+ <div id="scroller-2" class="scroller">
+ <div class="box"></div>
+ <div class="box blue"></div>
+ </div>
+ <br>
+ <div id="scroller-3" class="scroller">
+ <div class="box"></div>
+ <div class="box blue"></div>
+ </div>
+ <div id="scroller-4" class="scroller">
+ <div class="box"></div>
+ <div class="box green"></div>
+ </div>
+ <br>
+ <div id="scroller-5" class="scroller">
+ <div class="box blue"></div>
+ <div class="box half-shift green"></div>
+ </div>
+ <div id="scroller-6" class="scroller">
+ <div class="box"></div>
+ <div class="box green"></div>
+ </div>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html
new file mode 100644
index 0000000000..e2ca394ec0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html
@@ -0,0 +1,153 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="match" href="range-boundary-ref.html">
+ <title></title>
+</head>
+<style type="text/css">
+ @keyframes transform {
+ 0% { transform: translateX(25px); }
+ 100% { transform: translateX(50px); }
+ }
+
+ @keyframes background {
+ 0% { background-color: #99f; }
+ 100% { background-color: #9f9; }
+ }
+
+ .scroller {
+ display: inline-block;
+ border: 2px solid black;
+ height: 100px;
+ width: 100px;
+ overflow: hidden;
+ }
+ .spacer {
+ height: 300px;
+ margin: 0;
+ }
+ .box {
+ background: gray;
+ height: 50px;
+ width: 50px;
+ margin: 0;
+ animation: transform auto, background auto;
+ animation-timeline: view(), view();
+ animation-range: entry 0% entry 100%, contain 0% contain 100%;
+ }
+</style>
+<body>
+ <!-- scroll to bottom
+ top-box:
+ transform: none (after phase)
+ bg-color: #9f9 (at active-after boundary with inclusive endpoint)
+ bottom-box:
+ transform: 100px (at active-after boundary with inclusive endpoint)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-1" class="scroller">
+ <div class="spacer"></div>
+ <div class="box"></div>
+ <div class="box"></div>
+ </div>
+ <!-- scroll to top
+ top-box:
+ transform: none (after phase)
+ bg-color: gray (at active-after boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-after boundary with exclusive endpoint)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-2" class="scroller">
+ <div class="box"></div>
+ <div class="box"></div>
+ <div class="spacer"></div>
+ </div>
+ <br>
+ <!-- scroll to midpoint
+ top-box:
+ transform: none (after phase)
+ bg-color: gray (at active-after boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-after boundary with exclusive endpoint)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-3" class="scroller">
+ <div class="spacer"></div>
+ <div class="box"></div>
+ <div class="box"></div>
+ <div class="spacer"></div>
+ </div>
+ <!-- scroll to bottom + reverse
+ top-box:
+ transform: none (before phase)
+ bg-color: gray (at active-before boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-before boundary with exclusive endpoint)
+ bg-color: #9f9 (at active-after boundary with inclusive endpoint)
+ -->
+ <div id="scroller-4" class="scroller">
+ <div class="spacer"></div>
+ <div class="box reverse"></div>
+ <div class="box reverse"></div>
+ </div>
+ <br>
+ <!-- scroll to top + reverse
+ top-box:
+ transform: none (before phase)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ bottom-box:
+ transform: 25px (at active-before boundary with inclusive endpoint)
+ bg-color: #9f9 (at active-after boundary with inclusive endpoint)
+ -->
+ <div id="scroller-5" class="scroller">
+ <div class="box reverse"></div>
+ <div class="box reverse"></div>
+ <div class="spacer"></div>
+ </div>
+ <!-- scroll to midpoint + reverse
+ top-box:
+ transform: none (before phase)
+ bg-color: gray (at active-before boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-before boundary with exclusive endpoint)
+ bg-color: #9f9 (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-6" class="scroller">
+ <div class="spacer"></div>
+ <div class="box reverse"></div>
+ <div class="box reverse"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script src="/common/reftest-wait.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ function scrollTo(scroller_id, relative_offset) {
+ const scroller = document.getElementById(scroller_id);
+ const max_scroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = relative_offset * max_scroll;
+ }
+
+ window.onload = async () => {
+ await waitForCompositorReady();
+ document.querySelectorAll('.reverse').forEach(elem => {
+ elem.getAnimations().forEach(anim => {
+ anim.reverse();
+ });
+ });
+ // Playing forward
+ scrollTo('scroller-1', 1);
+ scrollTo('scroller-2', 0);
+ scrollTo('scroller-3', 0.5);
+ // Playing reverse
+ scrollTo('scroller-4', 1);
+ scrollTo('scroller-5', 0);
+ scrollTo('scroller-6', 0.5);
+ await waitForNextFrame();
+ takeScreenshot();
+ };
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html
new file mode 100644
index 0000000000..d8756769c5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* top-sticky during entry */
+.stickycase1 {
+ background: yellow;
+ position: sticky;
+ top: 400px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase1">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline top-sticky during entry.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html
new file mode 100644
index 0000000000..2d098dcbe3
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* bottom-sticky during entry and top-sticky during exit */
+.stickycase2 {
+ background: yellow;
+ position: sticky;
+ top: -100px;
+ bottom: -100px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase2">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + TARGET_HEIGHT,
+ endOffset: STATIC_END - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline bottom-sticky during entry and top-sticky during exit.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html
new file mode 100644
index 0000000000..c87dfc4dcb
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* top-sticky and bottom-sticky during entry */
+.stickycase3 {
+ background: yellow;
+ position: sticky;
+ top: 375px;
+ bottom: -125px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase3">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline top-sticky and bottom-sticky during entry.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html
new file mode 100644
index 0000000000..f6b02ffb2e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* top-sticky before entry */
+.stickycase4 {
+ background: yellow;
+ position: sticky;
+ top: 600px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase4">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline top-sticky before entry.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html
new file mode 100644
index 0000000000..380c01297e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* bottom-sticky before entry and top-sticky after exit */
+.stickycase5 {
+ background: yellow;
+ position: sticky;
+ top: -200px;
+ bottom: -200px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase5">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_END,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + TARGET_HEIGHT,
+ endOffset: STATIC_END - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END,
+ axis: 'block'
+ });
+}, 'View timeline bottom-sticky before entry and top-sticky after exit.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html
new file mode 100644
index 0000000000..94f0abc9b1
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html
@@ -0,0 +1,127 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* target > viewport, bottom-sticky during entry and top-sticky during exit */
+.stickycase6 {
+ background: yellow;
+ position: sticky;
+ top: -200px;
+ bottom: -200px;
+ height: 700px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 600px;
+}
+
+.space:has(.stickycase6),
+.space:has(.stickycase7) {
+ height: 1050px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase6">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const BIG_TARGET_STATIC_END = 1350;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const BIG_TARGET_HEIGHT = 600;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: BIG_TARGET_STATIC_END - VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline target > viewport, ' +
+ 'bottom-sticky during entry and top-sticky during exit.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html
new file mode 100644
index 0000000000..83115249fa
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html
@@ -0,0 +1,128 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* target > viewport, bottom-sticky and top-sticky during contain */
+.stickycase7 {
+ background: yellow;
+ position: sticky;
+ top: -100px;
+ bottom: -100px;
+ height: 700px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 600px;
+}
+
+.space:has(.stickycase6),
+.space:has(.stickycase7) {
+ height: 1050px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase7">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const BIG_TARGET_STATIC_END = 1350;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const BIG_TARGET_HEIGHT = 600;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ sticky.className = "stickycase7";
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START - ROOM_ABOVE + VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: BIG_TARGET_STATIC_END + ROOM_BELOW - VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline target > viewport, ' +
+ 'bottom-sticky and top-sticky during contain.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html b/testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html
new file mode 100644
index 0000000000..36627dbea6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<link rel="help" href="https://www.w3.org/TR/scroll-animations-1/#viewtimeline-interface">
+<html>
+<!-- crbug.com/1470522 --->
+<script>
+ function main() {
+ var b = document.createElement("br");
+ document.body.append(b);
+ new ViewTimeline({ subject: b });
+ }
+</script>
+<body onload=main()>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html
new file mode 100644
index 0000000000..9b100a0b64
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element</title>
+</head>
+<style type="text/css">
+ @keyframes stroke {
+ from { stroke: rgb(0, 0, 254); }
+ to { stroke: rgb(0, 128, 0); }
+ }
+
+ #line {
+ animation: stroke auto linear both;
+ animation-timeline: view();
+ animation-range: exit-crossing;
+ }
+ .spacer {
+ height: 100vh;
+ }
+</style>
+<body>
+<svg width="100" height="3000" stroke="red" stroke-width="5">
+ <path id="line" d="M 50 0 V 3000"></path>
+</svg>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const target = document.getElementById('line');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 0, 254)');
+ scroller.scrollTop =
+ 0.5*(scroller.scrollHeight - scroller.clientHeight);
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 64, 127)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html
new file mode 100644
index 0000000000..e173a649ef
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element in a nested &lt;svg></title>
+</head>
+<style type="text/css">
+ @keyframes stroke {
+ from { stroke: rgb(0, 0, 254); }
+ to { stroke: rgb(0, 128, 0); }
+ }
+
+ #line {
+ animation: stroke auto linear both;
+ animation-timeline: view();
+ animation-range: exit-crossing;
+ }
+ .spacer {
+ height: 100vh;
+ }
+</style>
+<body>
+<svg width="100" height="3000" stroke="red" stroke-width="5">
+ <svg>
+ <path id="line" d="M 50 0 V 3000"></path>
+ </svg>
+</svg>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const target = document.getElementById('line');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 0, 254)');
+ scroller.scrollTop =
+ 0.5*(scroller.scrollHeight - scroller.clientHeight);
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 64, 127)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html
new file mode 100644
index 0000000000..48e238c8ed
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element (&lt;foreignObject>)</title>
+</head>
+<style type="text/css">
+ @keyframes color {
+ from { color: rgb(0, 0, 254); }
+ to { color: rgb(0, 128, 0); }
+ }
+
+ #fo {
+ animation: color auto linear both;
+ animation-timeline: view();
+ animation-range: exit-crossing;
+ }
+ .spacer {
+ height: 100vh;
+ }
+</style>
+<body>
+<svg width="100" height="3000" color="red">
+ <foreignObject id="fo" x="47.5" width="3000" height="5"
+ transform="rotate(90, 47.5, 0)">
+ <div style="width: 100%; height: 200%; background-color: currentcolor"></div>
+ </foreignObject>
+</svg>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const target = document.getElementById('fo');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(target).color, 'rgb(0, 0, 254)');
+ scroller.scrollTop =
+ 0.5*(scroller.scrollHeight - scroller.clientHeight);
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).color, 'rgb(0, 64, 127)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js b/testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js
new file mode 100644
index 0000000000..a798fe918d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js
@@ -0,0 +1,146 @@
+'use strict';
+
+function assert_px_equals(observed, expected, description) {
+ assert_equals(observed.unit, 'px',
+ `Unexpected unit type for '${description}'`);
+ assert_approx_equals(observed.value, expected, 0.0001,
+ `Unexpected value for ${description}`);
+}
+
+function CreateViewTimelineOpacityAnimation(test, target, options) {
+ const timeline_options = {
+ subject: target,
+ axis: 'block'
+ };
+ if (options && 'timeline' in options) {
+ for (let key in options.timeline) {
+ timeline_options[key] = options.timeline[key];
+ }
+ }
+ const animation_options = {
+ timeline: new ViewTimeline(timeline_options)
+ };
+ if (options && 'animation' in options) {
+ for (let key in options.animation) {
+ animation_options[key] = options.animation[key];
+ }
+ }
+
+ const anim =
+ target.animate({ opacity: [0.3, 0.7] }, animation_options);
+ test.add_cleanup(() => {
+ anim.cancel();
+ });
+ return anim;
+}
+
+// Verify that range specified in the options aligns with the active range of
+// the animation.
+//
+// Sample call:
+// await runTimelineBoundsTest(t, {
+// timeline: { inset: [ CSS.percent(0), CSS.percent(20)] },
+// timing: { fill: 'both' }
+// startOffset: 600,
+// endOffset: 900
+// });
+async function runTimelineBoundsTest(t, options, message) {
+ const scrollOffsetProp = options.axis == 'block' ? 'scrollTop' : 'scrollLeft';
+ container[scrollOffsetProp] = 0;
+ await waitForNextFrame();
+
+ const anim =
+ options.anim ||
+ CreateViewTimelineOpacityAnimation(t, target, options);
+ if (options.timing)
+ anim.effect.updateTiming(options.timing);
+
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container[scrollOffsetProp] = options.startOffset;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ `Effect at the start of the active phase: ${message}`);
+
+ // Advance to the midpoint of the animation.
+ container[scrollOffsetProp] = (options.startOffset + options.endOffset) / 2;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ `Effect at the midpoint of the active range: ${message}`);
+
+ // Advance to the end of the animation.
+ container[scrollOffsetProp] = options.endOffset;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.7',
+ `Effect is in the active phase at effect end time: ${message}`);
+
+ // Return the animation so that we can continue testing with the same object.
+ return anim;
+}
+
+// Sets the start and end range for a view timeline and ensures that the
+// range aligns with expected values.
+//
+// Sample call:
+// await runTimelineRangeTest(t, {
+// rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+// rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+// startOffset: 600,
+// endOffset: 900
+// });
+async function runTimelineRangeTest(t, options) {
+ const rangeToString = range => {
+ const parts = [];
+ if (range.rangeName)
+ parts.push(range.rangeName);
+ if (range.offset)
+ parts.push(`${range.offset.value}%`);
+ return parts.join(' ');
+ };
+ const range =
+ `${rangeToString(options.rangeStart)} to ` +
+ `${rangeToString(options.rangeEnd)}`;
+
+ options.timeline = {
+ axis: options.axis || 'inline'
+ };
+ options.animation = {
+ rangeStart: options.rangeStart,
+ rangeEnd: options.rangeEnd,
+ };
+ options.timing = {
+ // Set fill to accommodate floating point precision errors at the
+ // endpoints.
+ fill: 'both'
+ };
+
+ return runTimelineBoundsTest(t, options, range);
+}
+
+// Sets the Inset for a view timeline and ensures that the range aligns with
+// expected values.
+//
+// Sample call:
+// await runTimelineInsetTest(t, {
+// inset: [ CSS.px(20), CSS.px(40) ]
+// startOffset: 600,
+// endOffset: 900
+// });
+async function runTimelineInsetTest(t, options) {
+ options.timeline = {
+ axis: 'inline',
+ inset: options.inset
+ };
+ options.timing = {
+ // Set fill to accommodate floating point precision errors at the
+ // endpoints.
+ fill: 'both'
+ }
+ const length = options.inset.length;
+ const range =
+ (options.inset instanceof Array) ? options.inset.join(' ')
+ : options.inset;
+ return runTimelineBoundsTest(t, options, range);
+}
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html b/testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html
new file mode 100644
index 0000000000..1168893854
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html
@@ -0,0 +1,264 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin: 800px 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function assert_progress_equals(anim, expected, errorMessage) {
+ assert_approx_equals(
+ anim.effect.getComputedTiming().progress,
+ expected, 1e-6, errorMessage);
+ }
+
+ function assert_opacity_equals(expected, errorMessage) {
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), expected, 1e-6,
+ errorMessage);
+ }
+
+ async function runTimelineOffsetsInKeyframesTest(keyframes) {
+ const testcase = JSON.stringify(keyframes);
+ const anim = target.animate(keyframes, {
+ timeline: new ViewTimeline( { subject: target }),
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto', fill: 'both'
+ });
+ await anim.ready;
+ await waitForNextFrame();
+
+ // @ contain 0%
+ scroller.scrollTop = 700;
+ await waitForNextFrame();
+
+ assert_progress_equals(
+ anim, 0, `Testcase '${testcase}': progress at contain 0%`);
+ assert_opacity_equals(
+ 1/3, `Testcase '${testcase}': opacity at contain 0%`);
+
+ // @ contain 50%
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 0.5, `Testcase '${testcase}': progress at contain 50%`);
+ assert_opacity_equals(
+ 0.5, `Testcase '${testcase}': opacity at contain 50%`);
+
+ // @ contain 100%
+ scroller.scrollTop = 800;
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 1, `Testcase '${testcase}': progress at contain 100%`);
+ assert_opacity_equals(
+ 2/3, `Testcase '${testcase}': opacity at contain 100%`);
+ anim.cancel();
+ }
+
+ async function runParseNumberOrPercentInKeyframesTest(keyframes) {
+ const anim = target.animate(keyframes, {
+ timeline: new ViewTimeline( { subject: target }),
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto', fill: 'both'
+ });
+ await anim.ready;
+ await waitForNextFrame();
+
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll / 2;
+ await waitForNextFrame();
+
+ const testcase = JSON.stringify(keyframes);
+ assert_progress_equals(anim, 0.5, testcase);
+ assert_opacity_equals(0.5, testcase);
+ anim.cancel();
+ }
+
+ async function runInvalidKeyframesTest(keyframes) {
+ assert_throws_js(TypeError, () => {
+ target.animate(keyframes, {
+ timeline: new ViewTimeline( { subject: target }),
+ });
+ }, `Invalid keyframes test case "${JSON.stringify(keyframes)}"`);
+ }
+
+ promise_test(async t => {
+ // Test equivalent typed-OM and CSS representations of timeline offsets.
+ // Test array and object form for keyframes.
+ const keyframeTests = [
+ // BaseKeyframe form with offsets expressed as typed-OM.
+ [
+ {
+ offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ opacity: 0
+ },
+ {
+ offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ opacity: 1
+ }
+ ],
+ // BaseKeyframe form with offsets expressed as CSS text.
+ [
+ { offset: "cover 0%", opacity: 0 },
+ { offset: "cover 100%", opacity: 1 }
+ ],
+ // BasePropertyIndexedKeyframe form with offsets expressed as typed-OM.
+ {
+ opacity: [0, 1],
+ offset: [
+ { rangeName: 'cover', offset: CSS.percent(0) },
+ { rangeName: 'cover', offset: CSS.percent(100) }
+ ]
+ },
+ // BasePropertyIndexedKeyframe form with offsets expressed as CSS text.
+ { opacity: [0, 1], offset: [ "cover 0%", "cover 100%" ]}
+ ];
+
+ for (let i = 0; i < keyframeTests.length; i++) {
+ await runTimelineOffsetsInKeyframesTest(keyframeTests[i]);
+ }
+
+ }, 'Timeline offsets in programmatic keyframes');
+
+ promise_test(async t => {
+ const keyframeTests = [
+ [{offset: "0.5", opacity: 0.5 }],
+ [{offset: "50%", opacity: 0.5 }],
+ [{offset: "calc(20% + 30%)", opacity: 0.5 }]
+ ];
+
+ for (let i = 0; i < keyframeTests.length; i++) {
+ await runParseNumberOrPercentInKeyframesTest(keyframeTests[i]);
+ }
+
+ }, 'String offsets in programmatic keyframes');
+
+ promise_test(async t => {
+ const invalidKeyframeTests = [
+ // BasePropertyKefyrame:
+ [{ offset: { rangeName: 'somewhere', offset: CSS.percent(0) }}],
+ [{ offset: { rangeName: 'entry', offset: CSS.px(0) }}],
+ [{ offset: "here 0%" }],
+ [{ offset: "entry 3px" }],
+ // BasePropertyIndexedKeyframe with sequence:
+ { offset: [{ rangeName: 'somewhere', offset: CSS.percent(0) }]},
+ { offset: [{ rangeName: 'entry', offset: CSS.px(0) }]},
+ { offset: ["here 0%"] },
+ { offset: ["entry 3px" ]},
+ // BasePropertyIndexedKeyframe without sequence:
+ { offset: { rangeName: 'somewhere', offset: CSS.percent(0) }},
+ { offset: { rangeName: 'entry', offset: CSS.px(0) }},
+ { offset: "here 0%" },
+ { offset: "entry 3px" },
+ // <number> or <percent> as string:
+ [{ offset: "-1" }],
+ [{ offset: "2" }],
+ [{ offset: "-10%" }],
+ [{ offset: "110%" }],
+ { offset: ["-1"], opacity: [0.5] },
+ { offset: ["2"], opacity: [0.5] },
+ { offset: "-1", opacity: 0.5 },
+ { offset: "2", opacity: 0.5 },
+ // Extra stuff at the end.
+ [{ offset: "0.5 trailing nonsense" }],
+ [{ offset: "cover 50% eureka" }]
+ ];
+ for( let i = 0; i < invalidKeyframeTests.length; i++) {
+ await runInvalidKeyframesTest(invalidKeyframeTests[i]);
+ }
+ }, 'Invalid timeline offset in programmatic keyframe throws');
+
+
+ promise_test(async t => {
+ const anim = target.animate([
+ { offset: "cover 0%", opacity: 0 },
+ { offset: "cover 100%", opacity: 1 }
+ ], {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 10000, fill: 'both'
+ });
+
+ scroller.scrollTop = 750;
+
+ await anim.ready;
+ assert_opacity_equals(1, `Opacity with document timeline`);
+
+ anim.timeline = new ViewTimeline( { subject: target });
+ await anim.ready;
+
+ assert_progress_equals(anim, 0.5, `Progress at contain 50%`);
+ assert_opacity_equals(0.5, `Opacity at contain 50%`);
+
+ anim.timeline = document.timeline;
+ assert_false(anim.pending);
+ await waitForNextFrame();
+ assert_opacity_equals(1, `Opacity after resetting timeline`);
+
+ anim.cancel();
+ }, 'Timeline offsets in programmatic keyframes adjust for change in ' +
+ 'timeline');
+
+ promise_test(async t => {
+ const anim = target.animate([], {
+ timeline: new ViewTimeline( { subject: target }),
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto', fill: 'both'
+ });
+
+ await anim.ready;
+ await waitForNextFrame();
+
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 0.5, `Progress at contain 50% before effect change`);
+ assert_opacity_equals(1, `Opacity at contain 50% before effect change`);
+
+ anim.effect = new KeyframeEffect(target, [
+ { offset: "cover 0%", opacity: 0 },
+ { offset: "cover 100%", opacity: 1 }
+ ], { duration: 'auto', fill: 'both' });
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 0.5, `Progress at contain 50% after effect change`);
+ assert_opacity_equals(0.5, `Opacity at contain 50% after effect change`);
+ }, 'Timeline offsets in programmatic keyframes resolved when updating ' +
+ 'the animation effect');
+ }
+
+ // TODO(kevers): Add tests for getKeyframes once
+ // https://github.com/w3c/csswg-drafts/issues/8507 is resolved.
+
+ window.onload = runTest;
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html b/testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html
new file mode 100644
index 0000000000..86262db8f8
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Test construction of a view timeline with a detached subject</title>
+</head>
+<style type="text/css">
+ #container {
+ overflow: hidden;
+ height: 200px;
+ width: 200px;
+ }
+
+ #block {
+ background: green;
+ height: 100px;
+ width: 100px;
+ }
+
+ .filler {
+ height: 200px;
+ }
+</style>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<body>
+ <div id="container">
+ <div class="filler"></div>
+ </div>
+</body>
+<script>
+ promise_test(async t => {
+ const element = document.createElement('div');
+ element.id = 'block';
+ const timeline = new ViewTimeline({
+ subject: element,
+ inset: new CSSMathNegate(CSS.px(144))
+ });
+ assert_equals(timeline.source, null, 'Null source while detached');
+ await waitForNextFrame();
+ const scroller = document.getElementById('container');
+ scroller.appendChild(element);
+ assert_equals(timeline.source, scroller, 'Source resolved once attached');
+ await waitForNextFrame();
+
+ // Start offset = cover 0%
+ // = target offset - viewport height + end side inset
+ // = 200 - 200 + (-144) = -144
+ assert_equals(timeline.startOffset.toString(), CSS.px(-144).toString());
+ // End offset = cover 100%
+ // = target offset + target height - start side inset
+ // = 200 + 100 - (-144) = 444
+ assert_equals(timeline.endOffset.toString(), CSS.px(444).toString());
+ }, 'Creating a view timeline with a subject that is not attached to the ' +
+ 'document works as expected');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html
new file mode 100644
index 0000000000..25e477e1a9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html
@@ -0,0 +1,148 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ const MAX_SCROLL = 1600;
+
+ promise_test(async t => {
+ // Points of interest along view timeline:
+ // 600 px cover start, entry start
+ // 700 px contain start, entry end
+ // 800 px contain end, exit start
+ // 900 px cover end, exit end
+ const anim =
+ CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline: { axis: 'inline' },
+ animation: { fill: 'both' }
+ });
+ let timeline = anim.timeline;
+
+ container.scrollLeft = 600;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('cover'), 0,
+ MAX_SCROLL, 'Scroll aligned with cover start');
+ assert_percents_approx_equal(timeline.getCurrentTime('entry'), 0,
+ MAX_SCROLL, 'Scroll aligned with entry start');
+ assert_percents_approx_equal(timeline.getCurrentTime(), 0,
+ MAX_SCROLL,
+ 'Scroll aligned with timeline start offset');
+
+ container.scrollLeft = 650;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('entry'), 50,
+ MAX_SCROLL, 'Scroll at entry midpoint');
+
+ container.scrollLeft = 700;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('entry'), 100,
+ MAX_SCROLL, 'Scroll at entry end');
+ assert_percents_approx_equal(timeline.getCurrentTime('contain'), 0,
+ MAX_SCROLL, 'Scroll at contain start');
+
+ container.scrollLeft = 750;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('contain'), 50,
+ MAX_SCROLL, 'Scroll at contain midpoint');
+ assert_percents_approx_equal(timeline.getCurrentTime(), 50,
+ MAX_SCROLL, 'Scroll at timeline midpoint');
+
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('exit'), 0,
+ MAX_SCROLL, 'Scroll at exit start');
+ assert_percents_approx_equal(timeline.getCurrentTime('contain'), 100,
+ MAX_SCROLL, 'Scroll at contain end');
+
+ container.scrollLeft = 850;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('exit'), 50,
+ MAX_SCROLL, 'Scroll at exit midpoint');
+
+ container.scrollLeft = 900;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('exit'), 100,
+ MAX_SCROLL, 'Scroll at exit end');
+ assert_percents_approx_equal(timeline.getCurrentTime('cover'), 100,
+ MAX_SCROLL, 'Scroll at cover end');
+ assert_percents_approx_equal(timeline.getCurrentTime(), 100,
+ MAX_SCROLL, 'Scroll at end of timeline');
+
+ assert_equals(timeline.getCurrentTime('gibberish'), null,
+ 'No current time for unknown named range');
+
+ // Add insets to force the start and end offsets to align. This forces
+ // the timeline to become inactive.
+ // start_offset = target_offset - viewport_size + end_side_inset
+ // = 600 + end_side_inset
+ // end_offset = target_offset + target_size - start_side_inset
+ // = 900 - start_side_inset
+ // Equating start_offset and end_offset:
+ // end_side_inset = 300 - start_side_inset;
+ timeline =
+ new ViewTimeline ({
+ subject: target,
+ axis: 'inline',
+ inset: [ CSS.px(150), CSS.px(150) ]
+ });
+ anim.timeline = timeline;
+ await waitForNextFrame();
+
+ assert_equals(timeline.currentTime, null,
+ 'Current time is null when scroll-range is zero');
+ assert_equals(timeline.getCurrentTime(), null,
+ 'getCurrentTime with an inactive timeline.');
+ assert_equals(timeline.getCurrentTime('contain'), null,
+ 'getCurrentTime on a ranged name with an inactive timeline.');
+
+ }, 'View timeline current time for named range');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html
new file mode 100644
index 0000000000..94660abcf2
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html
@@ -0,0 +1,127 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<script src="/css/css-typed-om/resources/testhelper.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ font-size: 10px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ function assert_timeline_offset(actual, expected, errorMessage) {
+ assert_equals(actual.rangeName, expected.rangeName, errorMessage);
+ assert_style_value_equals(actual.offset, expected.offset);
+ }
+
+ promise_test(async t => {
+ const timeline = new ViewTimeline({ subject: target, axis: 'inline' });
+ const anim = target.animate({ opacity: [0, 1 ] }, { timeline: timeline });
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ await anim.ready;
+
+ container.scrollLeft = 750;
+ await waitForNextFrame();
+
+ // normal ==> cover 0% to cover 100%
+ // cover 0% @ 600px
+ // cover 100% @ 900px
+ // expected opacity = (750 - 600) / (900 - 600) = 0.5
+ assert_equals(anim.rangeStart, 'normal', 'Initial value for rangeStart');
+ assert_equals(anim.rangeEnd, 'normal', 'Initial value for rangeEnd');
+ assert_equals(getComputedStyle(target).opacity, '0.5',
+ 'Opacity with range set to [normal, normal]');
+
+ // contain 0% @ 700px
+ // cover 100% @ 900px
+ // expected opacity = (750 - 700) / (900 - 700) = 0.25
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = "contain 0%";
+ anim.rangeEnd = "cover 100%";
+ });
+
+ assert_timeline_offset(
+ anim.rangeStart,
+ { rangeName: 'contain', offset: CSS.percent(0) },
+ 'rangeStart set to contain 0%');
+ assert_timeline_offset(
+ anim.rangeEnd,
+ { rangeName: 'cover', offset: CSS.percent(100) },
+ 'rangeEnd set to cover 100%');
+ assert_equals(getComputedStyle(target).opacity, '0.25',
+ 'opacity with range set to [contain 0%, cover 100%]');
+
+ // entry -20px @ 580px
+ // exit-crossing 10% @ 810px
+ // expected opacity = (750 - 580) / (810 - 580) = 0.739130
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = { rangeName: 'entry', offset: CSS.px(-20), };
+ anim.rangeEnd = { rangeName: 'exit-crossing', offset: CSS.percent(10) };
+ });
+ assert_timeline_offset(
+ anim.rangeStart,
+ { rangeName: 'entry', offset: CSS.px(-20) },
+ 'rangeStart set to entry -20px');
+ assert_timeline_offset(
+ anim.rangeEnd,
+ { rangeName: 'exit-crossing', offset: CSS.percent(10) },
+ 'rangeEnd set to exit-crossing 10%');
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), 0.739130, 1e-6,
+ 'opacity with range set to [entry -20px, exit-crossing 10%]');
+
+ // normal [start] @ 600px
+ // contain 100% @ 800px
+ // expected opacity = (750 - 600) / (800 - 600) = 0.75
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = "normal";
+ anim.rangeEnd = "contain calc(60% + 40%)";
+ });
+ assert_equals(anim.rangeStart, 'normal','rangeStart set to normal');
+ assert_timeline_offset(
+ anim.rangeEnd,
+ { rangeName: 'contain', offset: CSS.percent(100) },
+ 'rangeEnd set to contain 100%');
+ assert_equals(getComputedStyle(target).opacity, '0.75',
+ 'opacity with range set to [normal, contain 100%]');
+ }, 'Getting and setting the animation range');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html
new file mode 100644
index 0000000000..357d8558f9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html
@@ -0,0 +1,226 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ font-size: 16px;
+ }
+ #target.big-font {
+ font-size: 20px;
+ }
+ #container.scroll-padded {
+ scroll-padding-inline: 10px 20px;
+ }
+</style>
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+
+ function verifyTimelineOffsets(anim, start, end) {
+ const timeline = anim.timeline;
+ assert_px_equals(timeline.startOffset, start, 'startOffset');
+ assert_px_equals(timeline.endOffset, end, 'endOffset');
+ };
+
+ promise_test(async t => {
+ // These tests are all based on the cover range, which has bounds
+ // [600, 900] if there are no insets.
+ // startOffset = target_pos - viewport_size + end_side_inset
+ // = 600 + end_side_inset
+ // endOffset = target_pos + target_size - start_side_inset
+ // = 900 - start_side_inset
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.px(0), CSS.px(0) ],
+ startOffset: 600,
+ endOffset: 900
+ }).then(anim => verifyTimelineOffsets(anim, 600, 900));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.px(10), CSS.px(20) ],
+ startOffset: 620,
+ endOffset: 890
+ }).then(anim => verifyTimelineOffsets(anim, 620, 890));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.px(10) ],
+ startOffset: 610,
+ endOffset: 890
+ }).then(anim => verifyTimelineOffsets(anim, 610, 890));
+ }, 'View timeline with px based inset.');
+
+ promise_test(async t => {
+ // These tests are all based on the cover range, which has bounds
+ // [600, 900].
+ // Percentages are relative to the viewport size, which is 200 for this
+ // test.
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.percent(0), CSS.percent(0) ],
+ startOffset: 600,
+ endOffset: 900
+ }).then(anim => verifyTimelineOffsets(anim, 600, 900));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.percent(10), CSS.percent(20) ],
+ startOffset: 640,
+ endOffset: 880
+ }).then(anim => verifyTimelineOffsets(anim, 640, 880));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.percent(10) ],
+ startOffset: 620,
+ endOffset: 880
+ }).then(anim => verifyTimelineOffsets(anim, 620, 880));
+ }, 'View timeline with percent based inset.');
+
+ promise_test(async t => {
+ t.add_cleanup(() => {
+ container.classList.remove('scroll-padded');
+ });
+ const anim = await runTimelineInsetTest(t, {
+ inset: [ "auto", "auto" ],
+ startOffset: 600,
+ endOffset: 900
+ });
+ verifyTimelineOffsets(anim, 600, 900);
+ container.classList.add('scroll-padded');
+ await runTimelineBoundsTest(t, {
+ anim: anim,
+ startOffset: 620,
+ endOffset: 890,
+ }, 'Adjust for scroll-padding')
+ .then(anim => verifyTimelineOffsets(anim, 620, 890));
+ }, 'view timeline with inset auto.');
+
+promise_test(async t => {
+ t.add_cleanup(() => {
+ target.classList.remove('big-font');
+ });
+ const anim = await runTimelineInsetTest(t, {
+ inset: [ CSS.em(1), CSS.em(2) ],
+ startOffset: 632,
+ endOffset: 884
+ });
+ verifyTimelineOffsets(anim, 632, 884);
+ target.classList.add('big-font');
+ await runTimelineBoundsTest(t, {
+ anim: anim,
+ startOffset: 640,
+ endOffset: 880,
+ }, 'Adjust for font size increase')
+ .then(anim => verifyTimelineOffsets(anim, 640, 880));
+}, 'view timeline with font relative inset.');
+
+promise_test(async t => {
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const vmin = Math.min(vw, vh);
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.vw(10), CSS.vw(20) ],
+ startOffset: 600 + 0.2 * vw,
+ endOffset: 900 - 0.1 * vw
+ });
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.vmin(10), CSS.vmin(20) ],
+ startOffset: 600 + 0.2 * vmin,
+ endOffset: 900 - 0.1 * vmin
+ });
+}, 'view timeline with viewport relative insets.');
+
+promise_test(async t => {
+ await runTimelineInsetTest(t, {
+ inset: "10px",
+ startOffset: 610,
+ endOffset: 890
+ });
+ await runTimelineInsetTest(t, {
+ inset: "10px 20px",
+ startOffset: 620,
+ endOffset: 890
+ });
+ await runTimelineInsetTest(t, {
+ inset: "10%",
+ startOffset: 620,
+ endOffset: 880
+ });
+ await runTimelineInsetTest(t, {
+ inset: "10% 20%",
+ startOffset: 640,
+ endOffset: 880
+ });
+ await runTimelineInsetTest(t, {
+ inset: "auto",
+ startOffset: 600,
+ endOffset: 900
+ });
+ await runTimelineInsetTest(t, {
+ inset: "1em 2em",
+ startOffset: 632,
+ endOffset: 884
+ });
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: "go fish"
+ });
+ });
+
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: "1 2"
+ });
+ });
+
+}, 'view timeline inset as string');
+
+promise_test(async t => {
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: [ CSS.rad(1) ]
+ });
+ });
+
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: [ CSS.px(10), CSS.px(10), CSS.px(10) ]
+ });
+ });
+
+
+}, 'view timeline with invalid inset');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html
new file mode 100644
index 0000000000..01ca021524
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+<title>ViewTimeline with missing subject</title>
+<link rel="help" href="https://www.w3.org/TR/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+</head>
+<style type="text/css">
+ #target {
+ background: blue;
+ height: 100px;
+ width: 100px;
+ }
+ #scroller {
+ overflow: scroll;
+ }
+ #filler {
+ height: 300vh;
+ }
+</style>
+<body onload="runTests()">
+ <div id="scroller">
+ <div id="target"></div>
+ <div id="filler"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ function raf() {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(resolve);
+ })
+ });
+ }
+ function runTests() {
+ promise_test(async t => {
+ const timeline = new ViewTimeline();
+ const anim =
+ target.animate(
+ { backgroundColor: ['green', 'red' ] },
+ { duration: 100,
+ timeline: timeline });
+ await raf();
+ scroller.scrollTop = 50;
+ await raf();
+ assert_equals(timeline.currentTime, null,
+ 'ViewTimeline with missing subject is inactive');
+ });
+ }
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html
new file mode 100644
index 0000000000..1cc23fe626
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>View timeline on element with display:none</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timelines">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<script src="/css/css-typed-om/resources/testhelper.js"></script>
+
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: none;
+ }
+</style>
+
+<div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+</div>
+
+<script>
+promise_test(async t => {
+ const timeline = new ViewTimeline({ subject: target });
+ const anim = target.animate({ opacity: [0, 0.5] }, { timeline: timeline });
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ anim.rangeStart = "1em";
+ container.scrollLeft = 750;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, "1",
+ "Opacity with inactive timeline");
+}, "element with display: none should have inactive viewtimeline");
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html
new file mode 100644
index 0000000000..f87a57584e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html
@@ -0,0 +1,105 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 2100px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ /* target size > viewport size, which changes interpretation of the
+ contain range */
+ width: 400px;
+ display: inline-block;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 800
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 1000,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(-50) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(200) },
+ startOffset: 700,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry' },
+ rangeEnd: { rangeName: 'exit' },
+ startOffset: 600,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { offset: CSS.percent(0) },
+ rangeEnd: { offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 1200
+ });
+
+ }, 'View timeline with range set via delays.' );
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html
new file mode 100644
index 0000000000..5042c6c2a0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html
@@ -0,0 +1,198 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ font-size: 10px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ // Delays are associated with the animation and not with the timeline.
+ // Thus adjusting the delays has no effect on the timeline offsets. The
+ // offsets always correspond to the 'cover' range.
+ const verifyTimelineOffsets = anim => {
+ const timeline = anim.timeline;
+ assert_px_equals(timeline.startOffset, 600, 'startOffset');
+ assert_px_equals(timeline.endOffset, 900, 'endOffset');
+ };
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 900
+ }).then(anim => {
+ verifyTimelineOffsets(anim);
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 700,
+ endOffset: 800
+ }).then(anim => {
+ verifyTimelineOffsets(anim);
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 700
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 700
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 900
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 900
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(-50) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(200) },
+ startOffset: 650,
+ endOffset: 800
+ });
+ }, 'View timeline with range as <name> <percent> pair.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry' },
+ rangeEnd: { rangeName: 'exit' },
+ startOffset: 600,
+ endOffset: 900
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { offset: CSS.percent(0) },
+ rangeEnd: { offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 900
+ });
+ }, 'View timeline with range and inferred name or offset.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.px(100) },
+ startOffset: 620,
+ endOffset: 700
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.px(100) },
+ startOffset: 720,
+ endOffset: 800
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'entry', offset: CSS.px(100) },
+ startOffset: 620,
+ endOffset: 700
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'exit', offset: CSS.px(80) },
+ startOffset: 820,
+ endOffset: 880
+ });
+
+ }, 'View timeline with range as <name> <px> pair.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: {
+ rangeName: 'contain',
+ offset: new CSSMathSum(CSS.percent(0), CSS.px(20))
+ },
+ rangeEnd: {
+ rangeName: 'contain',
+ offset: new CSSMathSum(CSS.percent(100), CSS.px(-10))
+ },
+ startOffset: 720,
+ endOffset: 790
+ });
+
+ }, 'View timeline with range as <name> <percent+px> pair.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: "contain -50%",
+ rangeEnd: "entry 200%",
+ startOffset: 650,
+ endOffset: 800
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: "contain 20px",
+ rangeEnd: "contain 100px",
+ startOffset: 720,
+ endOffset: 800
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: "contain calc(0% + 20px)",
+ rangeEnd: "contain calc(100% - 10px)",
+ startOffset: 720,
+ endOffset: 790
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: "exit 2em",
+ rangeEnd: "exit 8em",
+ startOffset: 820,
+ endOffset: 880
+ });
+
+
+ }, 'View timeline with range as strings.');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html
new file mode 100644
index 0000000000..20ac9c5464
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #target {
+ margin: 200vh;
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="target"></div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ const timeline = new ViewTimeline({ subject: target });
+ const anim = target.animate({ opacity: [0, 1 ] },
+ { timeline: timeline,
+ rangeStart: "entry 0%",
+ rangeEnd: "entry 100%",
+ fill: "both" });
+ const scroller = document.scrollingElement;
+ const scrollRange = scroller.scrollHeight - scroller.clientHeight;
+
+ await anim.ready;
+
+ await waitForNextFrame();
+ scroller.scrollTop = scrollRange / 2;
+ await waitForNextFrame();
+
+ assert_equals(getComputedStyle(target).opacity, "1");
+ }, 'Test view-timeline with document scrolling element.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html
new file mode 100644
index 0000000000..5d68d37037
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<title>ViewTimeline vs. scroll-padding-*</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timelines">
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-progress-visibility-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ height: 200px;
+ width: 200px;
+ scroll-padding: 40px;
+ }
+ .spacer {
+ height: 800px;
+ }
+ #target {
+ background-color: green;
+ height: 200px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+</body>
+<script>
+ promise_test(async t => {
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ await anim.ready;
+
+ // 0%
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(anim.currentTime, 0);
+
+ // 50%
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(anim.currentTime, 50);
+
+ // 100%
+ container.scrollTop = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(anim.currentTime, 100);
+ }, 'Default ViewTimeline is not affected by scroll-padding');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html
new file mode 100644
index 0000000000..f8aabc8bdd
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline source</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+#outer {
+ height: 400px;
+ width: 400px;
+ overflow: clip;
+}
+
+#inner {
+ height: 300px;
+ width: 300px;
+ overflow: clip;
+}
+
+#outer.scroller,
+#inner.scroller {
+ overflow: scroll;
+}
+
+#spacer {
+ height: 1000px;
+}
+
+#target {
+ background: green;
+ height: 40px;
+ width: 40px;
+}
+</style>
+<body>
+ <div id="outer" class="scroller">
+ <div id="inner" class="scroller">
+ <div id="target"></div>
+ <div id="spacer"></div>
+ </div>
+ </div>
+</body>
+<script>
+'use strict';
+
+function resetScrollers() {
+ inner.classList.add('scroller');
+ outer.classList.add('scroller');
+}
+
+function assert_source_id(viewTimeline, expected) {
+ const source = viewTimeline.source;
+ assert_true(!!source, 'No source');
+ assert_equals(source.id, expected);
+}
+
+promise_test(async t => {
+ t.add_cleanup(resetScrollers);
+ const viewTimeline = new ViewTimeline({ subject: target });
+ assert_equals(viewTimeline.subject, target);
+ assert_source_id(viewTimeline, 'inner');
+
+ inner.classList.remove('scroller');
+ assert_source_id(viewTimeline, 'outer');
+
+ outer.classList.remove('scroller');
+ assert_source_id(viewTimeline, 'top');
+}, 'Default source for a View timeline is the nearest scroll ' +
+ 'ancestor to the subject');
+
+promise_test(async t => {
+ t.add_cleanup(resetScrollers);
+ const viewTimeline =
+ new ViewTimeline({ source: outer, subject: target });
+ assert_equals(viewTimeline.subject, target);
+ assert_source_id(viewTimeline, 'inner');
+}, 'View timeline ignores explicitly set source');
+
+promise_test(async t => {
+ t.add_cleanup(resetScrollers);
+ const viewTimeline =
+ new ViewTimeline({ subject: target });
+ assert_equals(viewTimeline.subject, target);
+ assert_source_id(viewTimeline, 'inner');
+
+ target.style = "display: none";
+ assert_equals(viewTimeline.source, null);
+
+}, 'View timeline source is null when display:none');
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html
new file mode 100644
index 0000000000..43b717560d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 500px;
+}
+#targetp {
+ background: yellow;
+ position: sticky;
+ top: 0px;
+ bottom: 0px;
+ height: 50px;
+}
+#target {
+ height: 50px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 200px"></div>
+ <div id="targetp">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 1000,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 50,
+ endOffset: 950,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000,
+ axis: 'block'
+ });
+}, 'View timeline with sticky target, block axis.' );
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html
new file mode 100644
index 0000000000..4dc8331d9f
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html
@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ width: 500px;
+ height: 500px;
+ overflow: auto;
+ white-space: nowrap;
+}
+.space {
+ display: inline-block;
+ width: 500px;
+ height: 400px;
+ white-space: nowrap;
+}
+#target {
+ display: inline-block;
+ background: yellow;
+ position: sticky;
+ left: 0px;
+ right: 0px;
+ width: 50px;
+ height: 400px;
+}
+
+</style>
+</head>
+<body>
+<div id="container"><!--
+ --><div class="space"></div><!--
+ --><div class="space"><!--
+ --><div style="display:inline-block; width:200px"></div><!--
+ --><div id="target"></div><!--
+ --></div><!--
+ --><div class="space"></div><!--
+--></div>
+<script type="text/javascript">
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 50,
+ endOffset: 950
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000
+ });
+}, 'View timeline with sticky target, block axis.' );
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html
new file mode 100644
index 0000000000..ee7ce90678
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline Subject size changes after creation of Animation</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ height: 400px;
+ width: 400px;
+ }
+ .spacer {
+ height: 500px;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+
+<script type="text/javascript">
+promise_test(async t => {
+ const options = {
+ timeline: { axis: 'y' },
+ animation: {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ // Set fill to accommodate floating point precision errors at the
+ // endpoints.
+ fill: 'both'
+ }
+ };
+
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target, options);
+ const timeline = anim.timeline;
+ anim.effect.updateTiming(options.timing);
+ await anim.ready;
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollTop = 100;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ `Effect at the start of the active phase`);
+
+ // Advance to the midpoint of the animation.
+ container.scrollTop = 150;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ `Effect at the midpoint of the active range`);
+
+ // Since the height of the target is cut in half, the animation should be at the end now.
+ target.style.height = '50px';
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.7',
+ `Effect at the end of the active range`);
+
+ // Advance to the midpoint of the animation again.
+ container.scrollTop = 125;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ `Effect at the midpoint of the active range again`);
+
+ }, 'View timeline with subject size change after the creation of the animation');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html
new file mode 100644
index 0000000000..4eec5d8f13
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<title>Animation range updates play state</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { background-color: blue; }
+ to { background-color: white; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-top: 800px;
+ margin-bottom: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto linear;
+ animation-timeline: --t1;
+ view-timeline: --t1;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ promise_test(async t => {
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+
+ let duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(100),
+ 'Default duration is 100%');
+
+ // Start and end boundaries coincide.
+ anim.rangeStart = "entry 100%";
+ anim.rangeEnd = "contain 0%";
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is zero when boundaries coincide");
+
+ // Start > end, clamp at zero duration.
+ anim.rangeEnd = "entry 0%"
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is zero when start > end");
+
+ anim.rangeStart = "normal";
+ anim.rangeEnd = "normal";
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(100),
+ "Duration is 100% after range reset");
+
+ // Consumed 100% of timeline duration with delays
+ anim.effect.updateTiming({
+ delay: CSS.percent(60),
+ endDelay: CSS.percent(40)
+ });
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is 0% after delays sum to 100%");
+
+ // Delays sum to > 100%
+ anim.effect.updateTiming({
+ delay: CSS.percent(60),
+ endDelay: CSS.percent(60)
+ });
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is 0% after delays sum to > 100%");
+
+ anim.effect.updateTiming({
+ delay: CSS.percent(40),
+ endDelay: CSS.percent(40)
+ });
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(
+ duration, CSS.percent(20),
+ "Duration is 20% if normal range and delays sum to 80%");
+
+ }, 'Intrinsic iteration duration is non-negative');
+ }
+
+
+ window.onload = runTest;
+</script>