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/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/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. +
+ First element +
+
+ Second element +
+ +``` + +## Legacy usage + +The `ScrollAnimation` component can be used standalone for simple use cases, but it is **deprecated** in favor of the timeline API. ```js{2,8} import { Base, createApp } from '@studiometa/js-toolkit'; @@ -32,8 +74,8 @@ export default createApp(App, document.body); ``` ```html -
@@ -51,5 +93,19 @@ 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: + +- `ScrollAnimation` → use `ScrollAnimationTimeline` and `ScrollAnimationTarget` +- `ScrollAnimationParent` → use `ScrollAnimationTimeline` +- `ScrollAnimationChild` → use `ScrollAnimationTarget` +- `ScrollAnimationChildWithEase` → use `ScrollAnimationTarget` +- `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 1036697e..7c1cb289 100644 --- a/packages/docs/components/ScrollAnimation/js-api.md +++ b/packages/docs/components/ScrollAnimation/js-api.md @@ -223,10 +223,116 @@ 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` - 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 the base animation class 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

-
-
{ + 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..bbbb9a49 --- /dev/null +++ b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts @@ -0,0 +1,139 @@ +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, +} 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(); + }); + + 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); + }); +}); 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..7aa2ed49 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. */ @@ -79,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/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..15d8837e --- /dev/null +++ b/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts @@ -0,0 +1,109 @@ +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'); + }); + + domScheduler.write(() => { + super.scrolledInView({ + ...props, + dampedCurrent: this.dampedCurrent, + dampedProgress: this.dampedProgress, + }); + }); + } +} 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';