From a5d0e29551c0e43e37b088a5493bba4d38053985 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 12 Jan 2026 20:42:31 +0100 Subject: [PATCH 1/5] Refactor ScrollAnimation exports - Add ScrollAnimationTimeline (new name for ScrollAnimationParent) - Add ScrollAnimationTarget (new name for ScrollAnimationChild) - Deprecate ScrollAnimation, ScrollAnimationWithEase, ScrollAnimationChild, ScrollAnimationChildWithEase, ScrollAnimationParent and animationScrollWithEase - Add tests for the new components Fixes #441 Co-authored-by: Claude --- CLAUDE.md | 5 + .../ScrollAnimationTarget.spec.ts | 53 +++++++++ .../ScrollAnimationTimeline.spec.ts | 93 +++++++++++++++ packages/tests/ScrollAnimation/index.spec.ts | 28 +++-- .../ui/ScrollAnimation/ScrollAnimation.ts | 15 +++ .../ScrollAnimation/ScrollAnimationChild.ts | 16 ++- .../ScrollAnimationChildWithEase.ts | 17 ++- .../ScrollAnimation/ScrollAnimationParent.ts | 15 +++ .../ScrollAnimation/ScrollAnimationTarget.ts | 107 ++++++++++++++++++ .../ScrollAnimationTimeline.ts | 48 ++++++++ .../ScrollAnimationWithEase.ts | 17 ++- .../animationScrollWithEase.ts | 16 ++- packages/ui/ScrollAnimation/index.ts | 4 + 13 files changed, 423 insertions(+), 11 deletions(-) create mode 100644 packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts create mode 100644 packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts create mode 100644 packages/ui/ScrollAnimation/ScrollAnimationTarget.ts create mode 100644 packages/ui/ScrollAnimation/ScrollAnimationTimeline.ts diff --git a/CLAUDE.md b/CLAUDE.md index 89d9d949..5916beb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,10 @@ # @studiometa/ui packages +## Commit messages + +- Use English for commit messages +- Use simple verb-first sentences (e.g., "Add...", "Fix...", "Refactor...") + ## Project structure - Monorepo managed by NPM with packages in the `./packages` folder diff --git a/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts new file mode 100644 index 00000000..1c851c64 --- /dev/null +++ b/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ScrollAnimationTarget } from '@studiometa/ui'; +import { h, mount, destroy } from '#test-utils'; + +describe('ScrollAnimationTarget', () => { + let element: HTMLDivElement; + let animation: ScrollAnimationTarget; + + beforeEach(async () => { + element = h('div'); + animation = new ScrollAnimationTarget(element); + await mount(animation); + }); + + afterEach(async () => { + await destroy(animation); + }); + + it('should have the correct config', () => { + expect(ScrollAnimationTarget.config.name).toBe('ScrollAnimationTarget'); + expect(ScrollAnimationTarget.config.options.dampFactor.default).toBe(0.1); + expect(ScrollAnimationTarget.config.options.dampPrecision.default).toBe(0.001); + }); + + it('should initialize with correct default damped values', () => { + expect(animation.dampedCurrent).toEqual({ x: 0, y: 0 }); + expect(animation.dampedProgress).toEqual({ x: 0, y: 0 }); + }); + + it('should have damping options accessible', () => { + expect(animation.$options.dampFactor).toBe(0.1); + expect(animation.$options.dampPrecision).toBe(0.001); + }); + + it('should override scrolledInView method', () => { + const mockProps = { + current: { x: 0.5, y: 0.8 }, + dampedCurrent: { x: 0.4, y: 0.7 }, + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + dampedProgress: { x: 0.4, y: 0.7 }, + progress: { x: 0.5, y: 0.8 }, + }; + + expect(() => animation.scrolledInView(mockProps)).not.toThrow(); + }); + + it('should inherit from AbstractScrollAnimation', () => { + expect(animation.render).toBeDefined(); + expect(animation.target).toBe(element); + expect(animation.playRange).toEqual([0, 1]); + }); +}); diff --git a/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts new file mode 100644 index 00000000..924d5003 --- /dev/null +++ b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; +import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui'; +import { + h, + mockIsIntersecting, + intersectionObserverBeforeAllCallback, + intersectionObserverAfterEachCallback, +} from '#test-utils'; + +describe('ScrollAnimationTimeline', () => { + let parentElement: HTMLDivElement; + let childElement1: HTMLDivElement; + let childElement2: HTMLDivElement; + let parent: ScrollAnimationTimeline; + + beforeAll(() => { + intersectionObserverBeforeAllCallback(); + }); + + afterEach(() => { + intersectionObserverAfterEachCallback(); + }); + + beforeEach(async () => { + parentElement = h('div'); + childElement1 = h('div', { 'data-component': 'ScrollAnimationTarget' }); + childElement2 = h('div', { 'data-component': 'ScrollAnimationTarget' }); + + parentElement.appendChild(childElement1); + parentElement.appendChild(childElement2); + + parent = new ScrollAnimationTimeline(parentElement); + await mockIsIntersecting(parentElement, true); + }); + + afterEach(async () => { + await mockIsIntersecting(parentElement, false); + }); + + it('should have the correct config', () => { + expect(ScrollAnimationTimeline.config.name).toBe('ScrollAnimationTimeline'); + expect(ScrollAnimationTimeline.config.components.ScrollAnimationTarget).toBe(ScrollAnimationTarget); + }); + + it('should have ScrollAnimationTarget components', () => { + expect(parent.$children.ScrollAnimationTarget).toHaveLength(2); + expect(parent.$children.ScrollAnimationTarget[0]).toBeInstanceOf(ScrollAnimationTarget); + expect(parent.$children.ScrollAnimationTarget[1]).toBeInstanceOf(ScrollAnimationTarget); + }); + + it('should propagate scrolledInView to all children', () => { + const child1Spy = vi.spyOn(parent.$children.ScrollAnimationTarget[0], 'scrolledInView'); + const child2Spy = vi.spyOn(parent.$children.ScrollAnimationTarget[1], 'scrolledInView'); + + const mockProps = { + current: { x: 0.5, y: 0.8 }, + dampedCurrent: { x: 0.4, y: 0.7 }, + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + dampedProgress: { x: 0.4, y: 0.7 }, + progress: { x: 0.5, y: 0.8 }, + }; + + parent.scrolledInView(mockProps); + + expect(child1Spy).toHaveBeenCalledWith(mockProps); + expect(child2Spy).toHaveBeenCalledWith(mockProps); + }); + + it('should work with no children', async () => { + const emptyParent = new ScrollAnimationTimeline(h('div')); + await mockIsIntersecting(emptyParent.$el, true); + + expect(emptyParent.$children.ScrollAnimationTarget).toHaveLength(0); + + const mockProps = { + current: { x: 0.5, y: 0.8 }, + dampedCurrent: { x: 0.4, y: 0.7 }, + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + dampedProgress: { x: 0.4, y: 0.7 }, + progress: { x: 0.5, y: 0.8 }, + }; + + expect(() => emptyParent.scrolledInView(mockProps)).not.toThrow(); + + await mockIsIntersecting(emptyParent.$el, false); + }); + + it('should be extended from withScrolledInView(Base)', () => { + expect(parent.scrolledInView).toBeDefined(); + }); +}); diff --git a/packages/tests/ScrollAnimation/index.spec.ts b/packages/tests/ScrollAnimation/index.spec.ts index 9271a828..f9659a72 100644 --- a/packages/tests/ScrollAnimation/index.spec.ts +++ b/packages/tests/ScrollAnimation/index.spec.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest'; import { AbstractScrollAnimation, + ScrollAnimationTimeline, + ScrollAnimationTarget, + // Deprecated exports ScrollAnimation, ScrollAnimationChild, ScrollAnimationChildWithEase, @@ -15,33 +18,44 @@ describe('ScrollAnimation exports', () => { expect(AbstractScrollAnimation.config.name).toBe('AbstractScrollAnimation'); }); - it('should export ScrollAnimation', () => { + it('should export ScrollAnimationTimeline', () => { + expect(ScrollAnimationTimeline).toBeDefined(); + expect(ScrollAnimationTimeline.config.name).toBe('ScrollAnimationTimeline'); + }); + + it('should export ScrollAnimationTarget', () => { + expect(ScrollAnimationTarget).toBeDefined(); + expect(ScrollAnimationTarget.config.name).toBe('ScrollAnimationTarget'); + }); + + // Deprecated exports - kept for backward compatibility + it('should export ScrollAnimation (deprecated)', () => { expect(ScrollAnimation).toBeDefined(); expect(ScrollAnimation.config.name).toBe('ScrollAnimation'); }); - it('should export ScrollAnimationChild', () => { + it('should export ScrollAnimationChild (deprecated)', () => { expect(ScrollAnimationChild).toBeDefined(); expect(ScrollAnimationChild.config.name).toBe('AbstractScrollAnimation'); }); - it('should export ScrollAnimationChildWithEase', () => { + it('should export ScrollAnimationChildWithEase (deprecated)', () => { expect(ScrollAnimationChildWithEase).toBeDefined(); expect(ScrollAnimationChildWithEase.config.name).toBe('ScrollAnimationChildWithEase'); }); - it('should export ScrollAnimationParent', () => { + it('should export ScrollAnimationParent (deprecated)', () => { expect(ScrollAnimationParent).toBeDefined(); expect(ScrollAnimationParent.config.name).toBe('ScrollAnimationParent'); }); - it('should export ScrollAnimationWithEase', () => { + it('should export ScrollAnimationWithEase (deprecated)', () => { expect(ScrollAnimationWithEase).toBeDefined(); expect(ScrollAnimationWithEase.config.name).toBe('ScrollAnimationWithEase'); }); - it('should export animationScrollWithEase decorator', () => { + it('should export animationScrollWithEase decorator (deprecated)', () => { expect(animationScrollWithEase).toBeDefined(); expect(typeof animationScrollWithEase).toBe('function'); }); -}); \ No newline at end of file +}); diff --git a/packages/ui/ScrollAnimation/ScrollAnimation.ts b/packages/ui/ScrollAnimation/ScrollAnimation.ts index 36a45413..80d01a24 100644 --- a/packages/ui/ScrollAnimation/ScrollAnimation.ts +++ b/packages/ui/ScrollAnimation/ScrollAnimation.ts @@ -1,5 +1,6 @@ import { withScrolledInView } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { isDev } from '@studiometa/js-toolkit/utils'; import { AbstractScrollAnimation } from './AbstractScrollAnimation.js'; export interface ScrollAnimationProps extends BaseProps { @@ -10,6 +11,8 @@ export interface ScrollAnimationProps extends BaseProps { /** * ScrollAnimation class. + * + * @deprecated Use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead. * @link https://ui.studiometa.dev/components/ScrollAnimation/ */ export class ScrollAnimation< @@ -32,4 +35,16 @@ export class ScrollAnimation< get target(): HTMLElement { return this.$refs.target; } + + /** + * Display deprecation warning. + */ + mounted() { + if (isDev) { + console.warn( + `The ${this.$options.name} component is deprecated.`, + '\nUse `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.', + ); + } + } } diff --git a/packages/ui/ScrollAnimation/ScrollAnimationChild.ts b/packages/ui/ScrollAnimation/ScrollAnimationChild.ts index 40f9547f..ed217012 100644 --- a/packages/ui/ScrollAnimation/ScrollAnimationChild.ts +++ b/packages/ui/ScrollAnimation/ScrollAnimationChild.ts @@ -4,7 +4,7 @@ import type { ScrollInViewProps, WithScrolledInViewProps, } from '@studiometa/js-toolkit'; -import { damp, clamp01, domScheduler } from '@studiometa/js-toolkit/utils'; +import { damp, clamp01, domScheduler, isDev } from '@studiometa/js-toolkit/utils'; import { AbstractScrollAnimation } from './AbstractScrollAnimation.js'; export interface ScrollAnimationChildProps extends BaseProps { @@ -32,6 +32,8 @@ function updateProps( /** * ScrollAnimationChild class. + * + * @deprecated Use `ScrollAnimationTarget` instead. */ export class ScrollAnimationChild extends AbstractScrollAnimation< T & ScrollAnimationChildProps @@ -71,6 +73,18 @@ export class ScrollAnimationChild extends Abstr y: 0, }; + /** + * Display deprecation warning. + */ + mounted() { + if (isDev) { + console.warn( + `The ${this.$options.name} component is deprecated.`, + '\nUse `ScrollAnimationTarget` instead.', + ); + } + } + /** * Compute local damped progress. */ diff --git a/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts b/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts index 39170b59..00c17ecb 100644 --- a/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts +++ b/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts @@ -1,9 +1,12 @@ import type { BaseConfig } from '@studiometa/js-toolkit'; +import { isDev } from '@studiometa/js-toolkit/utils'; import { ScrollAnimationChild } from './ScrollAnimationChild.js'; import { animationScrollWithEase } from './animationScrollWithEase.js'; /** - * ScrollAnimationChild class. + * ScrollAnimationChildWithEase class. + * + * @deprecated Use `ScrollAnimationTarget` instead. */ export class ScrollAnimationChildWithEase extends animationScrollWithEase(ScrollAnimationChild) { /** @@ -13,4 +16,16 @@ export class ScrollAnimationChildWithEase extends animationScrollWithEase(Scroll ...ScrollAnimationChild.config, name: 'ScrollAnimationChildWithEase', }; + + /** + * Display deprecation warning. + */ + mounted() { + if (isDev) { + console.warn( + `The ${this.$options.name} component is deprecated.`, + '\nUse `ScrollAnimationTarget` instead.', + ); + } + } } diff --git a/packages/ui/ScrollAnimation/ScrollAnimationParent.ts b/packages/ui/ScrollAnimation/ScrollAnimationParent.ts index a9885d10..d7cb3fef 100644 --- a/packages/ui/ScrollAnimation/ScrollAnimationParent.ts +++ b/packages/ui/ScrollAnimation/ScrollAnimationParent.ts @@ -1,5 +1,6 @@ import { Base, ScrollInViewProps, withScrolledInView } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { isDev } from '@studiometa/js-toolkit/utils'; import { ScrollAnimationChild } from './ScrollAnimationChild.js'; export interface ScrollAnimationParentProps extends BaseProps { @@ -10,6 +11,8 @@ export interface ScrollAnimationParentProps extends BaseProps { /** * ScrollAnimationParent class. + * + * @deprecated Use `ScrollAnimationTimeline` instead. */ export class ScrollAnimationParent extends withScrolledInView( Base, @@ -25,6 +28,18 @@ export class ScrollAnimationParent extends with }, }; + /** + * Display deprecation warning. + */ + mounted() { + if (isDev) { + console.warn( + `The ${this.$options.name} component is deprecated.`, + '\nUse `ScrollAnimationTimeline` instead.', + ); + } + } + /** * Scrolled in view hook. */ diff --git a/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts b/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts new file mode 100644 index 00000000..63e1b91d --- /dev/null +++ b/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts @@ -0,0 +1,107 @@ +import type { + BaseConfig, + BaseProps, + ScrollInViewProps, + WithScrolledInViewProps, +} from '@studiometa/js-toolkit'; +import { damp, clamp01, domScheduler } from '@studiometa/js-toolkit/utils'; +import { AbstractScrollAnimation } from './AbstractScrollAnimation.js'; + +export interface ScrollAnimationTargetProps extends BaseProps { + $options: WithScrolledInViewProps['$options']; +} + +function updateProps( + // eslint-disable-next-line no-use-before-define + that: ScrollAnimationTarget, + props: ScrollInViewProps, + dampFactor: number, + dampPrecision: number, + axis: 'x' | 'y' = 'x', +) { + that.dampedCurrent[axis] = damp( + props.current[axis], + that.dampedCurrent[axis], + dampFactor, + dampPrecision, + ); + that.dampedProgress[axis] = clamp01( + (that.dampedCurrent[axis] - props.start[axis]) / (props.end[axis] - props.start[axis]), + ); +} + +/** + * ScrollAnimationTarget class. + * + * A component that animates based on scroll progress from a parent `ScrollAnimationTimeline`. + * Each target can have its own animation keyframes and play range. + * + * @example + * ```html + *
+ *
+ * Animated content + *
+ *
+ * ``` + */ +export class ScrollAnimationTarget extends AbstractScrollAnimation< + T & ScrollAnimationTargetProps +> { + /** + * Config. + */ + static config: BaseConfig = { + ...AbstractScrollAnimation.config, + name: 'ScrollAnimationTarget', + options: { + ...AbstractScrollAnimation.config.options, + dampFactor: { + type: Number, + default: 0.1, + }, + dampPrecision: { + type: Number, + default: 0.001, + }, + }, + }; + + /** + * Local damped current values. + */ + dampedCurrent: ScrollInViewProps['dampedCurrent'] = { + x: 0, + y: 0, + }; + + /** + * Local damped progress. + */ + dampedProgress: ScrollInViewProps['dampedCurrent'] = { + x: 0, + y: 0, + }; + + /** + * Compute local damped progress. + */ + scrolledInView(props: ScrollInViewProps) { + domScheduler.read(() => { + const { dampFactor, dampPrecision } = this.$options; + updateProps(this, props, dampFactor, dampPrecision, 'x'); + updateProps(this, props, dampFactor, dampPrecision, 'y'); + props.dampedCurrent = this.dampedCurrent; + props.dampedProgress = this.dampedProgress; + }); + + domScheduler.write(() => { + super.scrolledInView(props); + }); + } +} diff --git a/packages/ui/ScrollAnimation/ScrollAnimationTimeline.ts b/packages/ui/ScrollAnimation/ScrollAnimationTimeline.ts new file mode 100644 index 00000000..bfbc017f --- /dev/null +++ b/packages/ui/ScrollAnimation/ScrollAnimationTimeline.ts @@ -0,0 +1,48 @@ +import { Base, ScrollInViewProps, withScrolledInView } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { ScrollAnimationTarget } from './ScrollAnimationTarget.js'; + +export interface ScrollAnimationTimelineProps extends BaseProps { + $children: { + ScrollAnimationTarget: ScrollAnimationTarget[]; + }; +} + +/** + * ScrollAnimationTimeline class. + * + * A component that manages scroll-based animations for its children. + * Use with `ScrollAnimationTarget` children components. + * + * @example + * ```html + *
+ *
+ * Content + *
+ *
+ * ``` + */ +export class ScrollAnimationTimeline extends withScrolledInView( + Base, + {}, +) { + /** + * Config. + */ + static config: BaseConfig = { + name: 'ScrollAnimationTimeline', + components: { + ScrollAnimationTarget, + }, + }; + + /** + * Scrolled in view hook. + */ + scrolledInView(props: ScrollInViewProps) { + for (const child of this.$children.ScrollAnimationTarget) { + child.scrolledInView(props); + } + } +} diff --git a/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts b/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts index 076672c3..98b5d62b 100644 --- a/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts +++ b/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts @@ -1,9 +1,12 @@ import { type BaseConfig } from '@studiometa/js-toolkit'; +import { isDev } from '@studiometa/js-toolkit/utils'; import { ScrollAnimation } from './ScrollAnimation.js'; import { animationScrollWithEase } from './animationScrollWithEase.js'; /** - * ScrollAnimation class. + * ScrollAnimationWithEase class. + * + * @deprecated Use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead. */ export class ScrollAnimationWithEase extends animationScrollWithEase(ScrollAnimation) { /** @@ -13,4 +16,16 @@ export class ScrollAnimationWithEase extends animationScrollWithEase(ScrollAnima ...ScrollAnimation.config, name: 'ScrollAnimationWithEase', }; + + /** + * Display deprecation warning. + */ + mounted() { + if (isDev) { + console.warn( + `The ${this.$options.name} component is deprecated.`, + '\nUse `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.', + ); + } + } } diff --git a/packages/ui/ScrollAnimation/animationScrollWithEase.ts b/packages/ui/ScrollAnimation/animationScrollWithEase.ts index ba1749c8..3463ca87 100644 --- a/packages/ui/ScrollAnimation/animationScrollWithEase.ts +++ b/packages/ui/ScrollAnimation/animationScrollWithEase.ts @@ -1,5 +1,5 @@ import type { BaseConfig, BaseProps, BaseDecorator, BaseInterface } from '@studiometa/js-toolkit'; -import { ease } from '@studiometa/js-toolkit/utils'; +import { ease, isDev } from '@studiometa/js-toolkit/utils'; import type { AbstractScrollAnimation } from './AbstractScrollAnimation.js'; const regex = /ease([A-Z])/; @@ -20,6 +20,8 @@ export interface AnimationScrollWithEaseInterface extends BaseInterface {} /** * Extend a `ScrollAnimation` component to use easings. + * + * @deprecated This decorator is deprecated. Easing can be applied directly via CSS or animation options. */ export function animationScrollWithEase( ScrollAnimation: typeof AbstractScrollAnimation, @@ -40,6 +42,18 @@ export function animationScrollWithEase( }, }; + /** + * Display a deprecation warning. + */ + mounted() { + if (isDev) { + console.warn( + `The animationScrollWithEase decorator is deprecated.`, + '\nEasing can be applied directly via CSS or animation options.', + ); + } + } + /** * Eases the progress value. */ diff --git a/packages/ui/ScrollAnimation/index.ts b/packages/ui/ScrollAnimation/index.ts index ac1f90bd..4507e87a 100644 --- a/packages/ui/ScrollAnimation/index.ts +++ b/packages/ui/ScrollAnimation/index.ts @@ -1,4 +1,8 @@ export * from './AbstractScrollAnimation.js'; +export * from './ScrollAnimationTimeline.js'; +export * from './ScrollAnimationTarget.js'; + +// Deprecated exports export * from './animationScrollWithEase.js'; export * from './ScrollAnimation.js'; export * from './ScrollAnimationWithEase.js'; From 9b9688bb0ce898da84b45df9d04faef0f56f4c9d Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 12 Jan 2026 21:00:04 +0100 Subject: [PATCH 2/5] Update ScrollAnimation documentation and changelog - Add documentation for ScrollAnimationTimeline and ScrollAnimationTarget - Update examples to use new component names - Add deprecation notices for old components - Update changelog with PR reference Co-authored-by: Claude Sonnet 4.5 --- CHANGELOG.md | 4 + .../components/ScrollAnimation/examples.md | 10 +- .../docs/components/ScrollAnimation/index.md | 60 +++++++++- .../docs/components/ScrollAnimation/js-api.md | 106 ++++++++++++++++++ .../stories/parallax-parent/app.js | 20 ++-- .../stories/parallax-parent/app.twig | 4 +- .../ScrollAnimation/stories/parent/app.js | 16 +-- .../ScrollAnimation/stories/parent/app.twig | 16 +-- 8 files changed, 198 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c494a761..55c022d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- **ScrollAnimation:** refactor exports ([#494](https://github.com/studiometa/ui/pull/494), [a5d0e29](https://github.com/studiometa/ui/commit/a5d0e29)) + ## [v1.7.0](https://github.com/studiometa/ui/compare/1.6.0..1.7.0) (2025-11-11) ### Added diff --git a/packages/docs/components/ScrollAnimation/examples.md b/packages/docs/components/ScrollAnimation/examples.md index 2690f8ef..4673ae7a 100644 --- a/packages/docs/components/ScrollAnimation/examples.md +++ b/packages/docs/components/ScrollAnimation/examples.md @@ -25,7 +25,9 @@ title: ScrollAnimation examples -## Parent driven animation +## Timeline driven animation + +Coordinate multiple animations using `ScrollAnimationTimeline` with `ScrollAnimationTarget` children. -## Parallax with a parent +## Parallax with a timeline -It might be sometimes interesting to use the parent ↔ child logic of the `ScrollAnimation` component to improve performance, as only the parent progression in the viewport is watched. +It might be sometimes interesting to use the timeline ↔ target logic of the `ScrollAnimation` component to improve performance, as only the timeline progression in the viewport is watched. -The resulting effect is different as each child animation is driven by the parent one, but it is still interesting. +The resulting effect is different as each target animation is driven by the timeline, but it is still interesting. @@ -43,6 +43,49 @@ export default createApp(App, document.body); ``` +## Timeline usage + +For more complex animations with multiple targets, use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children: + +```js{2,3,9,10} +import { Base, createApp } from '@studiometa/js-toolkit'; +import { ScrollAnimationTimeline } from '@studiometa/ui'; +import { ScrollAnimationTarget } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + ScrollAnimationTimeline, + ScrollAnimationTarget, + }, + }; +} + +export default createApp(App, document.body); +``` + +```html +
+
+
First element
+
+
+
Second element
+
+
+``` + ## Features - **Scroll-driven**: Animations progress based on scroll position @@ -51,5 +94,18 @@ export default createApp(App, document.body); - **Easing Control**: Customize animation timing with cubic-bezier easing - **Play Range**: Control when animation starts and ends during scroll - **Performance**: Optimized with intersection observer and RAF +- **Timeline Support**: Coordinate multiple animations with `ScrollAnimationTimeline` and `ScrollAnimationTarget` - **Variants**: Multiple specialized classes for different use cases +## Deprecated components + +:::warning Deprecated +The following components are deprecated and will be removed in a future version. Use `ScrollAnimationTimeline` and `ScrollAnimationTarget` instead: + +- `ScrollAnimationParent` → use `ScrollAnimationTimeline` +- `ScrollAnimationChild` → use `ScrollAnimationTarget` +- `ScrollAnimationChildWithEase` → use `ScrollAnimationTarget` +- `ScrollAnimationWithEase` → use `ScrollAnimation` +- `animationScrollWithEase` → no replacement +::: + diff --git a/packages/docs/components/ScrollAnimation/js-api.md b/packages/docs/components/ScrollAnimation/js-api.md index 1036697e..6be1e8ef 100644 --- a/packages/docs/components/ScrollAnimation/js-api.md +++ b/packages/docs/components/ScrollAnimation/js-api.md @@ -230,3 +230,109 @@ The element being animated (either the `target` ref or the component's root elem - Type: `Animation` The animation instance created from the keyframes and easing options. See [`animate` documentation](https://js-toolkit.studiometa.dev/utils/css/animate.html). + +--- + +## ScrollAnimationTimeline + +A parent component that manages scroll-based animations for its children `ScrollAnimationTarget` components. + +### Usage + +```js +import { Base, createApp } from '@studiometa/js-toolkit'; +import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + ScrollAnimationTimeline, + ScrollAnimationTarget, + }, + }; +} + +export default createApp(App, document.body); +``` + +```html +
+
+
Content
+
+
+``` + +### Children Components + +#### `ScrollAnimationTarget` + +- Type: `ScrollAnimationTarget[]` + +Array of child animation targets that will be animated based on the scroll progress of the timeline. + +--- + +## ScrollAnimationTarget + +A component that animates based on scroll progress from a parent `ScrollAnimationTimeline`. Each target can have its own animation keyframes and play range. + +### Usage + +```html +
+
+
First element
+
+
+
Second element
+
+
+``` + +### Options + +`ScrollAnimationTarget` inherits all options from `ScrollAnimation` and adds: + +#### `dampFactor` + +- Type: `number` +- Default: `0.1` + +Damping factor for smooth scroll animations. Lower values create smoother, slower animations. + +#### `dampPrecision` + +- Type: `number` +- Default: `0.001` + +Precision threshold for damping calculations. Lower values increase precision but may impact performance. + +### Properties + +#### `dampedCurrent` + +- Type: `{ x: number, y: number }` + +Current damped scroll position values for both axes. + +#### `dampedProgress` + +- Type: `{ x: number, y: number }` + +Current damped progress values (0-1) for both axes. diff --git a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.js b/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.js index acbca604..995e4295 100644 --- a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.js +++ b/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.js @@ -1,10 +1,10 @@ import { Base, createApp } from '@studiometa/js-toolkit'; -import { Figure, ScrollAnimationChild, ScrollAnimationParent } from '@studiometa/ui'; +import { Figure, ScrollAnimationTarget, ScrollAnimationTimeline } from '@studiometa/ui'; -class ParallaxChild extends ScrollAnimationChild { +class ParallaxTarget extends ScrollAnimationTarget { static config = { - ...ScrollAnimationChild.config, - name: 'ParallaxChild', + ...ScrollAnimationTarget.config, + name: 'ParallaxTarget', components: { Figure, }, @@ -15,12 +15,12 @@ class ParallaxChild extends ScrollAnimationChild { } } -class ParallaxParent extends ScrollAnimationParent { +class ParallaxTimeline extends ScrollAnimationTimeline { static config = { - ...ScrollAnimationParent.config, - name: 'ParallaxParent', + ...ScrollAnimationTimeline.config, + name: 'ParallaxTimeline', components: { - ParallaxChild, + ParallaxTarget, }, }; @@ -29,7 +29,7 @@ class ParallaxParent extends ScrollAnimationParent { } scrolledInView(props) { - this.$children.ParallaxChild.forEach((child) => { + this.$children.ParallaxTarget.forEach((child) => { child.scrolledInView(props); }); } @@ -39,7 +39,7 @@ class App extends Base { static config = { name: 'App', components: { - ParallaxParent, + ParallaxTimeline, }, }; } diff --git a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.twig b/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.twig index 797ec736..1fe6ff61 100644 --- a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.twig +++ b/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.twig @@ -40,11 +40,11 @@ } ] %} -
+
{% include '@ui/ImageGrid/ImageGrid.twig' with { images: images, image_attr: { - data_component: 'ParallaxChild', + data_component: 'ParallaxTarget', data_option_from: { y: [-20, '%'] }, data_option_to: { y: [20, '%'] }, class: 'h-fit overflow-hidden', diff --git a/packages/docs/components/ScrollAnimation/stories/parent/app.js b/packages/docs/components/ScrollAnimation/stories/parent/app.js index 1e1abeec..c38f8b1b 100644 --- a/packages/docs/components/ScrollAnimation/stories/parent/app.js +++ b/packages/docs/components/ScrollAnimation/stories/parent/app.js @@ -1,23 +1,15 @@ import { Base, createApp } from '@studiometa/js-toolkit'; import { - ScrollAnimationParent as ScrollAnimationParentCore, - ScrollAnimationChild, + ScrollAnimationTimeline, + ScrollAnimationTarget, } from '@studiometa/ui'; -class ScrollAnimationParent extends ScrollAnimationParentCore { - static config = { - name: 'ScrollAnimationParent', - components: { - ScrollAnimationChild, - }, - }; -} - class App extends Base { static config = { name: 'App', components: { - ScrollAnimationParent, + ScrollAnimationTimeline, + ScrollAnimationTarget, }, }; } diff --git a/packages/docs/components/ScrollAnimation/stories/parent/app.twig b/packages/docs/components/ScrollAnimation/stories/parent/app.twig index f14fa81a..6e90a7e3 100644 --- a/packages/docs/components/ScrollAnimation/stories/parent/app.twig +++ b/packages/docs/components/ScrollAnimation/stories/parent/app.twig @@ -7,10 +7,10 @@
Scroll down
-
-
-
-
-
-
@@ -63,13 +63,13 @@ Lorem ipsum dolor sit amet

-
-
Date: Mon, 12 Jan 2026 21:43:18 +0100 Subject: [PATCH 3/5] Fix shared state mutation in ScrollAnimationTarget damping Co-authored-by: Claude --- packages/ui/ScrollAnimation/ScrollAnimationChild.ts | 8 +++++--- packages/ui/ScrollAnimation/ScrollAnimationTarget.ts | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/ui/ScrollAnimation/ScrollAnimationChild.ts b/packages/ui/ScrollAnimation/ScrollAnimationChild.ts index ed217012..7aa2ed49 100644 --- a/packages/ui/ScrollAnimation/ScrollAnimationChild.ts +++ b/packages/ui/ScrollAnimation/ScrollAnimationChild.ts @@ -93,12 +93,14 @@ export class ScrollAnimationChild extends Abstr const { dampFactor, dampPrecision } = this.$options; updateProps(this, props, dampFactor, dampPrecision, 'x'); updateProps(this, props, dampFactor, dampPrecision, 'y'); - props.dampedCurrent = this.dampedCurrent; - props.dampedProgress = this.dampedProgress; }); domScheduler.write(() => { - super.scrolledInView(props); + super.scrolledInView({ + ...props, + dampedCurrent: this.dampedCurrent, + dampedProgress: this.dampedProgress, + }); }); } } diff --git a/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts b/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts index 63e1b91d..15d8837e 100644 --- a/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts +++ b/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts @@ -96,12 +96,14 @@ export class ScrollAnimationTarget extends Abst const { dampFactor, dampPrecision } = this.$options; updateProps(this, props, dampFactor, dampPrecision, 'x'); updateProps(this, props, dampFactor, dampPrecision, 'y'); - props.dampedCurrent = this.dampedCurrent; - props.dampedProgress = this.dampedProgress; }); domScheduler.write(() => { - super.scrolledInView(props); + super.scrolledInView({ + ...props, + dampedCurrent: this.dampedCurrent, + dampedProgress: this.dampedProgress, + }); }); } } From 1e3c6be79c716bce4aae538a97bb8cd0e3fd0b2e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 12 Jan 2026 21:43:20 +0100 Subject: [PATCH 4/5] Add test case for independent damping in ScrollAnimationTimeline Co-authored-by: Claude --- .../ScrollAnimationTimeline.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts index 924d5003..bbbb9a49 100644 --- a/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts +++ b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui'; +import { domScheduler } from '@studiometa/js-toolkit/utils'; import { h, + destroy, mockIsIntersecting, intersectionObserverBeforeAllCallback, intersectionObserverAfterEachCallback, @@ -90,4 +92,48 @@ describe('ScrollAnimationTimeline', () => { it('should be extended from withScrolledInView(Base)', () => { expect(parent.scrolledInView).toBeDefined(); }); + + it('should not share dampedProgress between children', async () => { + parentElement = h('div'); + childElement1 = h('div', { + 'data-component': 'ScrollAnimationTarget', + 'data-option-damp-factor': '0.1', + }); + childElement2 = h('div', { + 'data-component': 'ScrollAnimationTarget', + 'data-option-damp-factor': '1', + }); + + parentElement.appendChild(childElement1); + parentElement.appendChild(childElement2); + + const timeline = new ScrollAnimationTimeline(parentElement); + await mockIsIntersecting(parentElement, true); + + const child1 = timeline.$children.ScrollAnimationTarget[0]; + const child2 = timeline.$children.ScrollAnimationTarget[1]; + + const child1RenderSpy = vi.spyOn(child1, 'render'); + const child2RenderSpy = vi.spyOn(child2, 'render'); + + const mockProps = { + current: { x: 0, y: 100 }, + start: { x: 0, y: 0 }, + end: { x: 0, y: 1000 }, + progress: { x: 0, y: 0.1 }, + dampedCurrent: { x: 0, y: 0 }, + dampedProgress: { x: 0, y: 0 }, + }; + + timeline.scrolledInView(mockProps as any); + + // Wait for domScheduler + await new Promise((resolve) => domScheduler.read(() => domScheduler.write(resolve))); + + expect(child1RenderSpy).toHaveBeenCalledWith(0.01); + expect(child2RenderSpy).toHaveBeenCalledWith(0.1); + + await mockIsIntersecting(parentElement, false); + await destroy(timeline); + }); }); From f925170c7c41fad4d14d1a0c5e43364df4b2b137 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 12 Jan 2026 21:43:22 +0100 Subject: [PATCH 5/5] Update ScrollAnimation documentation to promote Timeline API Co-authored-by: Claude --- .../docs/components/ScrollAnimation/index.md | 70 +++++++++---------- .../docs/components/ScrollAnimation/js-api.md | 10 +-- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/docs/components/ScrollAnimation/index.md b/packages/docs/components/ScrollAnimation/index.md index 338695f8..f0ebe68a 100644 --- a/packages/docs/components/ScrollAnimation/index.md +++ b/packages/docs/components/ScrollAnimation/index.md @@ -13,17 +13,18 @@ The `ScrollAnimation` component creates scroll-driven animations that respond to ## Usage -Once the [package installed](/guide/installation/), simply include the component in your project: +For the best performance and flexibility, use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children: -```js{2,8} +```js{2,3,9,10} import { Base, createApp } from '@studiometa/js-toolkit'; -import { ScrollAnimation } from '@studiometa/ui'; +import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui'; class App extends Base { static config = { name: 'App', components: { - ScrollAnimation, + ScrollAnimationTimeline, + ScrollAnimationTarget, }, }; } @@ -32,32 +33,39 @@ export default createApp(App, document.body); ``` ```html -
-
- Content to animate +
+
+ First element +
+
+ Second element
``` -## Timeline usage +## Legacy usage -For more complex animations with multiple targets, use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children: +The `ScrollAnimation` component can be used standalone for simple use cases, but it is **deprecated** in favor of the timeline API. -```js{2,3,9,10} +```js{2,8} import { Base, createApp } from '@studiometa/js-toolkit'; -import { ScrollAnimationTimeline } from '@studiometa/ui'; -import { ScrollAnimationTarget } from '@studiometa/ui'; +import { ScrollAnimation } from '@studiometa/ui'; class App extends Base { static config = { name: 'App', components: { - ScrollAnimationTimeline, - ScrollAnimationTarget, + ScrollAnimation, }, }; } @@ -66,22 +74,13 @@ export default createApp(App, document.body); ``` ```html -
-
-
First element
-
-
-
Second element
+
+
+ Content to animate
``` @@ -102,10 +101,11 @@ export default createApp(App, document.body); :::warning Deprecated The following components are deprecated and will be removed in a future version. Use `ScrollAnimationTimeline` and `ScrollAnimationTarget` instead: +- `ScrollAnimation` → use `ScrollAnimationTimeline` and `ScrollAnimationTarget` - `ScrollAnimationParent` → use `ScrollAnimationTimeline` - `ScrollAnimationChild` → use `ScrollAnimationTarget` - `ScrollAnimationChildWithEase` → use `ScrollAnimationTarget` -- `ScrollAnimationWithEase` → use `ScrollAnimation` +- `ScrollAnimationWithEase` → use `ScrollAnimationTimeline` and `ScrollAnimationTarget` - `animationScrollWithEase` → no replacement ::: diff --git a/packages/docs/components/ScrollAnimation/js-api.md b/packages/docs/components/ScrollAnimation/js-api.md index 6be1e8ef..7c1cb289 100644 --- a/packages/docs/components/ScrollAnimation/js-api.md +++ b/packages/docs/components/ScrollAnimation/js-api.md @@ -223,7 +223,7 @@ Manually render the animation at a specific progress value. - Type: `HTMLElement` -The element being animated (either the `target` ref or the component's root element). +The element being animated. For `ScrollAnimationTarget`, it defaults to the component's root element (`$el`). For the legacy `ScrollAnimation`, it uses the `target` ref if provided. ### `animation` @@ -263,7 +263,7 @@ export default createApp(App, document.body); data-option-from='{"opacity": 0}' data-option-to='{"opacity": 1}' > -
Content
+ Content
``` @@ -292,7 +292,7 @@ A component that animates based on scroll progress from a parent `ScrollAnimatio data-option-to='{"opacity": 1, "translateY": "0px"}' data-option-play-range='[0, 0.5]' > -
First element
+ First element
-
Second element
+ Second element
``` ### Options -`ScrollAnimationTarget` inherits all options from `ScrollAnimation` and adds: +`ScrollAnimationTarget` inherits all options from the base animation class and adds: #### `dampFactor`