From 5f18c392c7ba2dc8446b47d875a922409fbd2524 Mon Sep 17 00:00:00 2001 From: Eliran Hezkia Date: Wed, 21 Oct 2026 14:57:19 +0300 Subject: [PATCH] fix(interact): honor selector conditions on viewProgress and pointerMove core/add.ts resolves an effect's `selector` condition and passes it to every trigger handler as `selectorCondition`. click, hover, viewEnter and animationEnd skip a target that doesn't match it, and the CSS that generate() writes is scoped by it too. viewProgress and pointerMove ignored it: every target got the scroll-driven animation or the pointer effect, so an effect meant for part of a list (e.g. every other repeater item) ran on all of them. Return early from both handlers when the target doesn't match. Scrub effects have no per-event callback to check in, so the check runs once, when the handler is added, before the animation, scroll scene or Pointer is created. Co-Authored-By: Claude Opus 5.5 --- packages/interact/src/handlers/pointerMove.ts | 4 +- .../interact/src/handlers/viewProgress.ts | 4 +- packages/interact/test/mini.spec.ts | 147 ++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/packages/interact/src/handlers/pointerMove.ts b/packages/interact/src/handlers/pointerMove.ts index 5e59b9af..bc787ed2 100644 --- a/packages/interact/src/handlers/pointerMove.ts +++ b/packages/interact/src/handlers/pointerMove.ts @@ -19,12 +19,14 @@ function addPointerMoveHandler( target: HTMLElement, effect: ScrubEffect, options: PointerMoveParams = {}, - { reducedMotion }: InteractOptions, + { reducedMotion, selectorCondition }: InteractOptions, ) { if (reducedMotion) { return; } + if (selectorCondition && !target.matches(selectorCondition)) return; + const triggerParams = { trigger: 'pointer-move' as const, element: source, diff --git a/packages/interact/src/handlers/viewProgress.ts b/packages/interact/src/handlers/viewProgress.ts index 9f44bd93..a722605a 100644 --- a/packages/interact/src/handlers/viewProgress.ts +++ b/packages/interact/src/handlers/viewProgress.ts @@ -20,12 +20,14 @@ function addViewProgressHandler( target: HTMLElement, effect: ScrubEffect, __: ViewEnterParams, - { reducedMotion }: InteractOptions, + { reducedMotion, selectorCondition }: InteractOptions, ): void { if (reducedMotion) { return; } + if (selectorCondition && !target.matches(selectorCondition)) return; + const triggerParams = { trigger: 'view-progress' as const, element: source, diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts index 72722348..8f801563 100644 --- a/packages/interact/test/mini.spec.ts +++ b/packages/interact/test/mini.spec.ts @@ -2538,6 +2538,153 @@ describe('interact (mini)', () => { expect(mockAnimation.play).toHaveBeenCalled(); }); + describe('scrub triggers on a list (repeater pattern)', () => { + // Every other list item, e.g. a repeater pattern that alternates the effect + const evenItemsConditions: InteractConfig['conditions'] = { + evenItems: { + type: 'selector', + predicate: ':nth-child(2n)', + }, + }; + + function createListElement() { + const root = document.createElement('div'); + const list = document.createElement('div'); + list.setAttribute('role', 'list'); + const items = Array.from({ length: 4 }, () => document.createElement('div')); + list.append(...items); + root.append(list); + + return { root, items }; + } + + function getViewProgressListConfig(): InteractConfig { + return { + conditions: evenItemsConditions, + interactions: [ + { + trigger: 'viewProgress', + key: 'repeater-scroll', + effects: [ + { + key: 'repeater-scroll', + listContainer: '[role="list"]', + effectId: 'repeater-fade-scroll', + conditions: ['evenItems'], + }, + ], + }, + ], + effects: { + 'repeater-fade-scroll': { + namedEffect: { + type: 'FadeScroll', + range: 'in', + opacity: 0, + } as NamedEffect, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, + }, + }, + }; + } + + it('should only create viewProgress animations for items matching the selector condition', async () => { + const { getWebAnimation: getWebAnimationFn } = await import('@wix/motion'); + const getWebAnimation = getWebAnimationFn as unknown as MockInstance; + const mockAnimation = { + play: vi.fn(), + cancel: vi.fn(), + ready: Promise.resolve(), + }; + getWebAnimation.mockReturnValue(mockAnimation); + + Interact.create(getViewProgressListConfig()); + + const { root, items } = createListElement(); + + add(root, 'repeater-scroll'); + + expect(getWebAnimation).toHaveBeenCalledTimes(2); + expect(getWebAnimation.mock.calls[0][0]).toBe(items[1]); + expect(getWebAnimation.mock.calls[1][0]).toBe(items[3]); + expect(mockAnimation.play).toHaveBeenCalledTimes(2); + }); + + it('should only create viewProgress scroll scenes for items matching the selector condition with fizban polyfill', async () => { + // Remove ViewTimeline support + delete (window as any).ViewTimeline; + const { getScrubScene: getScrubSceneFn } = await import('@wix/motion'); + const getScrubScene = getScrubSceneFn as unknown as MockInstance; + const { Scroll } = await import('fizban'); + Scroll.mockImplementation(function (this: any) { + Object.assign(this, { start: vi.fn(), destroy: vi.fn() }); + }); + + Interact.create(getViewProgressListConfig()); + + const { root, items } = createListElement(); + + add(root, 'repeater-scroll'); + + expect(getScrubScene).toHaveBeenCalledTimes(2); + expect(getScrubScene.mock.calls[0][0]).toBe(items[1]); + expect(getScrubScene.mock.calls[1][0]).toBe(items[3]); + expect(Scroll).toHaveBeenCalledTimes(2); + Scroll.mockRestore(); + }); + + it('should only apply pointerMove effects to items matching the selector condition', async () => { + const { getScrubScene: getScrubSceneFn } = await import('@wix/motion'); + const getScrubScene = getScrubSceneFn as unknown as MockInstance; + const { Pointer } = await import('kuliso'); + Pointer.mockImplementation(function (this: any) { + Object.assign(this, { start: vi.fn(), destroy: vi.fn() }); + }); + + Interact.create({ + conditions: evenItemsConditions, + interactions: [ + { + trigger: 'pointerMove', + key: 'repeater-mouse', + params: { + hitArea: 'root', + }, + effects: [ + { + key: 'repeater-mouse', + listContainer: '[role="list"]', + effectId: 'repeater-track-mouse', + conditions: ['evenItems'], + }, + ], + }, + ], + effects: { + 'repeater-track-mouse': { + namedEffect: { + type: 'TrackMouse', + distance: { value: 20, unit: 'px' }, + axis: 'both', + power: 'medium', + } as NamedEffect, + centeredToTarget: true, + }, + }, + }); + + const { root, items } = createListElement(); + + add(root, 'repeater-mouse'); + + expect(getScrubScene).toHaveBeenCalledTimes(2); + expect(getScrubScene.mock.calls[0][0]).toBe(items[1]); + expect(getScrubScene.mock.calls[1][0]).toBe(items[3]); + expect(Pointer).toHaveBeenCalledTimes(2); + }); + }); + it('should not apply selector condition type to media query listeners', () => { // Selector conditions should NOT create matchMedia listeners (only media conditions do) const config: InteractConfig = {