From 91eaa4d5ea2ca1051a076052a002b3a6d6a2349e Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Mon, 23 Mar 2026 10:58:03 +0200 Subject: [PATCH] Fixed viewEnter inset to negate when set to rootMargin; Added default threshold=0.2 --- packages/interact/src/handlers/viewEnter.ts | 35 +++++- packages/interact/test/viewEnter.spec.ts | 129 +++++++++++++++++++- 2 files changed, 156 insertions(+), 8 deletions(-) diff --git a/packages/interact/src/handlers/viewEnter.ts b/packages/interact/src/handlers/viewEnter.ts index 13a51bf7..932cdb19 100644 --- a/packages/interact/src/handlers/viewEnter.ts +++ b/packages/interact/src/handlers/viewEnter.ts @@ -21,6 +21,35 @@ const EXIT_OBSERVER_CONFIG: IntersectionObserverInit = { threshold: [0], }; +const DEFAULT_THRESHOLD = 0.2; + +/** + * Converts an `inset` value to a CSS `rootMargin` string. + * + * `inset` is one-dimensional: a single value applies to both top and bottom, + * two space-separated values set top and bottom independently. + * The inset direction is the inverse of rootMargin: a positive inset shrinks + * the intersection root (triggers later), so values are negated. + * + * Examples: + * "20%" → "-20% 0px -20%" + * "10% 30%" → "-10% 0px -30%" + */ +function insetToRootMargin(inset: string): string { + const parts = inset.trim().split(/\s+/); + const top = parts[0]; + const bottom = parts.length > 1 ? parts[1] : parts[0]; + + const negate = (value: string): string => { + if (value.startsWith('-')) { + return value.slice(1); + } + return parseFloat(value) ? `-${value}` : value; + }; + + return `${negate(top)} 0px ${negate(bottom)}`; +} + const observers: Record = {}; const handlerMap = new WeakMap() as HandlerObjectMap; const elementFirstRun = new WeakSet(); @@ -67,12 +96,14 @@ function getObserver(options: ViewEnterParams, isSafeMode: boolean = false) { return observers[key]; } + const threshold = options.threshold ?? DEFAULT_THRESHOLD; + const config: IntersectionObserverInit = isSafeMode ? SAFE_OBSERVER_CONFIG : { root: null, - rootMargin: options.inset ? `${options.inset} 0px ${options.inset}` : '0px', - threshold: options.threshold, + rootMargin: options.inset ? insetToRootMargin(options.inset) : '0px', + threshold, }; const observer = new IntersectionObserver((entries) => { diff --git a/packages/interact/test/viewEnter.spec.ts b/packages/interact/test/viewEnter.spec.ts index 2638538c..4a02ed29 100644 --- a/packages/interact/test/viewEnter.spec.ts +++ b/packages/interact/test/viewEnter.spec.ts @@ -30,7 +30,6 @@ describe('viewEnter handler', () => { let element: HTMLElement; let target: HTMLElement; let observerCallbacks: Array<(entries: Partial[]) => void>; - let observerMock: any; let observeSpy: MockInstance; let unobserveSpy: MockInstance; let IntersectionObserverMock: MockInstance; @@ -57,11 +56,6 @@ describe('viewEnter handler', () => { observeSpy = vi.fn(); unobserveSpy = vi.fn(); observerCallbacks = []; - observerMock = { - observe: observeSpy, - unobserve: unobserveSpy, - disconnect: vi.fn(), - }; IntersectionObserverMock = vi.fn(function (this: any, cb: any, _options: any) { observerCallbacks.push(cb); @@ -576,6 +570,129 @@ describe('viewEnter handler', () => { }); }); + describe('Observer configuration', () => { + describe('inset to rootMargin mapping', () => { + it('should negate a single inset value for both top and bottom', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + { inset: '20%' }, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.rootMargin).toBe('-20% 0px -20%'); + }); + + it('should negate two inset values independently for top and bottom', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + { inset: '10% 30%' }, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.rootMargin).toBe('-10% 0px -30%'); + }); + + it('should handle pixel inset values', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + { inset: '50px' }, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.rootMargin).toBe('-50px 0px -50px'); + }); + + it('should handle a mix of pixel and percent inset values', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + { inset: '50px 10%' }, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.rootMargin).toBe('-50px 0px -10%'); + }); + + it('should handle a negative inset value by removing the minus sign', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + { inset: '-20%' }, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.rootMargin).toBe('20% 0px 20%'); + }); + + it('should use "0px" rootMargin when no inset is provided', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + {}, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.rootMargin).toBe('0px'); + }); + }); + + describe('default threshold', () => { + it('should use 0.2 as the default threshold when not explicitly set', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + {}, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.threshold).toBe(0.2); + }); + + it('should use the explicitly provided threshold when set', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + { threshold: 0.5 }, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.threshold).toBe(0.5); + }); + + it('should respect threshold of 0 when explicitly set', () => { + viewEnterHandler.add( + element, + target, + { duration: 1000, namedEffect: { type: 'FadeIn' } }, + { threshold: 0 }, + {}, + ); + + const config = IntersectionObserverMock.mock.calls[0][1]; + expect(config.threshold).toBe(0); + }); + }); + }); + describe('Null animation handling', () => { it('should not create IntersectionObserver when animation is null', async () => { const { getAnimation } = await import('@wix/motion');