Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions packages/interact/src/handlers/viewEnter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IntersectionObserver> = {};
const handlerMap = new WeakMap() as HandlerObjectMap;
const elementFirstRun = new WeakSet<HTMLElement>();
Expand Down Expand Up @@ -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) => {
Expand Down
129 changes: 123 additions & 6 deletions packages/interact/test/viewEnter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ describe('viewEnter handler', () => {
let element: HTMLElement;
let target: HTMLElement;
let observerCallbacks: Array<(entries: Partial<IntersectionObserverEntry>[]) => void>;
let observerMock: any;
let observeSpy: MockInstance;
let unobserveSpy: MockInstance;
let IntersectionObserverMock: MockInstance;
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
Loading