UNPKG

html-trajectory

Version:

Animate HTML elements along a curved path from their current position to a target element. Eg. a product photo flying into the cart icon.

315 lines (236 loc) 11.1 kB
// tests/flyTo.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import flyTo from '../src/flyTo'; describe('flyTo', () => { let flyingEl: HTMLElement; let targetEl: HTMLElement; let cloneEl: HTMLElement; let mockGetElementById: any; let mockAppendChild: any; let mockRemove: any; let mockAddEventListener: any; let mockGetBoundingClientRect: any; beforeEach(() => { // Clean up the document document.body.innerHTML = ''; // Create actual DOM elements with happy-dom flyingEl = document.createElement('div'); flyingEl.id = 'flying-element'; document.body.appendChild(flyingEl); targetEl = document.createElement('div'); targetEl.id = 'target-element'; document.body.appendChild(targetEl); // Create clone element cloneEl = document.createElement('div'); // Mock getBoundingClientRect for all elements mockGetBoundingClientRect = vi.fn(); flyingEl.getBoundingClientRect = vi.fn().mockReturnValue({ top: 100, left: 100, right: 200, bottom: 200, width: 100, height: 100 }); targetEl.getBoundingClientRect = vi.fn().mockReturnValue({ top: 300, left: 300, right: 400, bottom: 400, width: 100, height: 100 }); cloneEl.getBoundingClientRect = vi.fn(); // Mock other methods mockAddEventListener = vi.fn(); cloneEl.addEventListener = mockAddEventListener; mockRemove = vi.fn(); cloneEl.remove = mockRemove; // Mock cloneNode to return our controlled clone flyingEl.cloneNode = vi.fn().mockReturnValue(cloneEl); // Mock document methods mockGetElementById = vi.spyOn(document, 'getElementById'); mockGetElementById.mockImplementation((id: string) => { if (id === 'flying-element') return flyingEl; if (id === 'target-element') return targetEl; return null; }); mockAppendChild = vi.spyOn(document.body, 'appendChild'); mockAppendChild.mockImplementation(() => cloneEl); }); afterEach(() => { vi.restoreAllMocks(); }); it('should do nothing if flying element is not found', () => { mockGetElementById.mockImplementation((id: string) => { if (id === 'flying-element') return null; if (id === 'target-element') return targetEl; return null; }); flyTo('flying-element', 'target-element'); // Should not be called when flying element is not found expect(mockAppendChild).not.toHaveBeenCalled(); }); it('should do nothing if target element is not found', () => { mockGetElementById.mockImplementation((id: string) => { if (id === 'flying-element') return flyingEl; if (id === 'target-element') return null; return null; }); flyTo('flying-element', 'target-element'); // Should not be called when target element is not found expect(mockAppendChild).not.toHaveBeenCalled(); }); it('should clone and append flying element to document body', () => { flyTo('flying-element', 'target-element'); expect(flyingEl.cloneNode).toHaveBeenCalledWith(true); expect(mockAppendChild).toHaveBeenCalled(); }); it('should set correct styles on cloned element', () => { flyTo('flying-element', 'target-element'); expect(cloneEl.style.position).toBe('absolute'); expect(cloneEl.style.top).toBe('100px'); expect(cloneEl.style.left).toBe('100px'); expect(cloneEl.style.width).toBe('100px'); expect(cloneEl.style.height).toBe('100px'); expect(cloneEl.style.zIndex).toBe('99'); expect(cloneEl.style.opacity).toBe('1'); expect(cloneEl.style.animation).toBe('none'); }); it('should set transform and transition properties with default durations', () => { flyTo('flying-element', 'target-element'); // The deltaX should be (350 - 150) = 200, deltaY should be (350 - 150) = 200 expect(cloneEl.style.transform).toBe('translate(200px, 200px) scale(0.1)'); // Use a regular expression to match the transition value, avoiding floating-point precision issues expect(cloneEl.style.transition).toMatch(/transform 0\.1\d+s ease, opacity 0\.4s ease/); }); it('should set custom durations when provided in options', () => { flyTo('flying-element', 'target-element', { duration: 1.0 }); // Use a regular expression to match the transition value, avoiding floating-point precision issues expect(cloneEl.style.transition).toMatch(/transform 0\.6\d*s ease, opacity 1s ease/); }); it('should add transitionend event listener', () => { flyTo('flying-element', 'target-element'); expect(mockAddEventListener).toHaveBeenCalledWith('transitionend', expect.any(Function)); }); it('should use custom scale value when provided', () => { flyTo('flying-element', 'target-element', { scale: 0.5 }); expect(cloneEl.style.transform).toBe('translate(200px, 200px) scale(0.5)'); }); it('should not move along X axis when moveX is false', () => { flyTo('flying-element', 'target-element', { moveX: false }); expect(cloneEl.style.transform).toBe('translate(0px, 200px) scale(0.1)'); }); it('should not move along Y axis when moveY is false', () => { flyTo('flying-element', 'target-element', { moveY: false }); expect(cloneEl.style.transform).toBe('translate(200px, 0px) scale(0.1)'); }); it('should not move along any axis when both moveX and moveY are false', () => { flyTo('flying-element', 'target-element', { moveX: false, moveY: false }); expect(cloneEl.style.transform).toBe('translate(0px, 0px) scale(0.1)'); }); it('should remove original element by default', () => { // Mock the remove method const originalRemove = vi.fn(); flyingEl.remove = originalRemove; flyTo('flying-element', 'target-element'); // Verify the original element was removed expect(originalRemove).toHaveBeenCalled(); }); it('should not remove original element when removeOriginal is false', () => { // Mock the remove method const originalRemove = vi.fn(); flyingEl.remove = originalRemove; flyTo('flying-element', 'target-element', { removeOriginal: false }); // Verify the original element was not removed expect(originalRemove).not.toHaveBeenCalled(); }); it('should set transformOrigin when resetTransformation is true', () => { // Set an initial transform on the cloned element cloneEl.style.transform = 'rotate(45deg) scale(0.8)'; cloneEl.style.transformOrigin = 'top left'; flyTo('flying-element', 'target-element', { resetTransformation: true }); // Verify the transformOrigin was reset // Note: We can't verify transform is 'none' because it's immediately overwritten // with the animation transform expect(cloneEl.style.transformOrigin).toBe('center center'); }); it('should not set transformOrigin when resetTransformation is false', () => { // Set an initial transform on the cloned element cloneEl.style.transform = 'rotate(45deg) scale(0.8)'; cloneEl.style.transformOrigin = 'top left'; flyTo('flying-element', 'target-element', { resetTransformation: false }); // Verify the transformOrigin was not changed expect(cloneEl.style.transformOrigin).not.toBe('center center'); }); it('should add html-trajectory-cloned class to cloned element', () => { flyTo('flying-element', 'target-element'); // Verify the html-trajectory-cloned class was added to the clone expect(cloneEl.classList.contains('html-trajectory-cloned')).toBe(true); // Verify the transformation styles are still applied expect(cloneEl.style.transform).toBe('translate(200px, 200px) scale(0.1)'); }); it('should call onTransitionEnd callback and remove clone when transition ends', () => { const onTransitionEnd = vi.fn(); flyTo('flying-element', 'target-element', { onTransitionEnd }); // Get the callback function passed to addEventListener const transitionEndCallback = mockAddEventListener.mock.calls[0][1]; // Simulate transition end transitionEndCallback(); expect(onTransitionEnd).toHaveBeenCalled(); expect(mockRemove).toHaveBeenCalled(); }); it('should work with default onTransitionEnd callback and remove clone when transition ends', () => { // Call flyTo without providing the onTransitionEnd callback flyTo('flying-element', 'target-element'); // Get the callback function passed to addEventListener const transitionEndCallback = mockAddEventListener.mock.calls[0][1]; // Verify that mockAddEventListener was called expect(mockAddEventListener).toHaveBeenCalledWith('transitionend', expect.any(Function)); // Simulate transition end transitionEndCallback(); // The element should still be removed even with the default callback expect(mockRemove).toHaveBeenCalled(); }); it('should only call onTransitionEnd once even if multiple transition events fire', () => { const onTransitionEnd = vi.fn(); flyTo('flying-element', 'target-element', { onTransitionEnd }); // Get the callback function passed to addEventListener const transitionEndCallback = mockAddEventListener.mock.calls[0][1]; // Mock the remove method to track calls and also clear the element reference // This helps prevent multiple calls to onTransitionEnd let removed = false; mockRemove.mockImplementation(() => { if (!removed) { removed = true; } }); // First call - should execute onTransitionEnd and remove transitionEndCallback(); // Reset the mock to verify the second call doesn't trigger it again onTransitionEnd.mockClear(); // Second call - should not execute onTransitionEnd again transitionEndCallback(); // Verify onTransitionEnd was only called once in total expect(onTransitionEnd).not.toHaveBeenCalled(); }); it('should remove clone even when onTransitionEnd throws an error', () => { // Create a callback that throws an error const onTransitionEnd = vi.fn().mockImplementation(() => { throw new Error('Intentional test error'); }); // Spy on console.error to prevent actual error output during tests const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); flyTo('flying-element', 'target-element', { onTransitionEnd }); // Get the callback function passed to addEventListener const transitionEndCallback = mockAddEventListener.mock.calls[0][1]; // Simulate transition end transitionEndCallback(); // Verify that onTransitionEnd was called and threw an error expect(onTransitionEnd).toHaveBeenCalled(); // Verify the clone was still removed despite the error expect(mockRemove).toHaveBeenCalled(); // Clean up the console.error spy consoleErrorSpy.mockRestore(); }); });