diff --git a/framework/core/js/src/forum/utils/slidable.js b/framework/core/js/src/forum/utils/slidable.js index 3becd587e1..de02b8c86b 100644 --- a/framework/core/js/src/forum/utils/slidable.js +++ b/framework/core/js/src/forum/utils/slidable.js @@ -140,7 +140,10 @@ export default function slidable(element) { activate($underneathRight); } else if ($underneathLeft.length && pos > threshold) { activate($underneathLeft); - } else { + } else if (pos !== 0) { + // Only animate back if the slider actually moved — skipping the + // animation on a plain tap prevents it from interfering with the + // browser's synthetic click event on iOS/touch devices. reset(); } diff --git a/framework/core/js/tests/unit/forum/utils/slidable.test.ts b/framework/core/js/tests/unit/forum/utils/slidable.test.ts new file mode 100644 index 0000000000..16372a9c2e --- /dev/null +++ b/framework/core/js/tests/unit/forum/utils/slidable.test.ts @@ -0,0 +1,174 @@ +import { jest } from '@jest/globals'; +import slidable from '../../../../src/forum/utils/slidable'; + +/** + * Helper — build a minimal DOM structure that slidable() expects: + * + *
+ * + * + *
+ * Discussion title + *
+ *
+ */ +function makeSlider() { + const el = document.createElement('div'); + el.className = 'DiscussionListItem Slidable'; + el.innerHTML = ` + + +
+ Discussion title +
+ `; + document.body.appendChild(el); + return el; +} + +/** + * Fire a jQuery touch event on the element with the given touch coordinates. + */ +function triggerTouch(el: Element, type: string, clientX: number, clientY: number) { + $(el).trigger( + $.Event(type, { + originalEvent: { + targetTouches: [{ clientX, clientY }], + preventDefault: () => {}, + }, + } as any) + ); +} + +beforeEach(() => { + // Stub jQuery animate to a no-op to avoid the infinite requestAnimationFrame + // loop that jQuery's animation scheduler enters in the jsdom test environment. + jest.spyOn($.fn, 'animate').mockImplementation(function (this: JQuery) { + return this; + }); +}); + +afterEach(() => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); +}); + +describe('slidable', () => { + describe('plain tap (no movement)', () => { + it('does not call animate on a plain tap — allows synthetic click to fire', () => { + const el = makeSlider(); + const content = el.querySelector('.Slidable-content') as HTMLElement; + + slidable(el); + + const animateSpy = $.fn.animate as jest.MockedFunction; + animateSpy.mockClear(); + + // Simulate a tap: touchstart then touchend at the same position. + triggerTouch(content, 'touchstart', 100, 200); + triggerTouch(content, 'touchend', 100, 200); + + expect(animateSpy).not.toHaveBeenCalled(); + }); + }); + + describe('partial slide (below threshold, slider moved)', () => { + it('calls animate to reset when slider moved but did not reach threshold', () => { + const el = makeSlider(); + + // Enable the left control so the slider is actually allowed to move rightward. + el.querySelector('.Slidable-underneath--left')!.classList.remove('disabled'); + + const content = el.querySelector('.Slidable-content') as HTMLElement; + + slidable(el); + + const animateSpy = $.fn.animate as jest.MockedFunction; + animateSpy.mockClear(); + + // Slide 20px to the right — below the 50px threshold. With a left control + // enabled, pos stays at 20 (not clamped to 0), so reset() should animate back. + triggerTouch(content, 'touchstart', 100, 200); + triggerTouch(content, 'touchmove', 120, 200); + triggerTouch(content, 'touchend', 120, 200); + + expect(animateSpy).toHaveBeenCalled(); + }); + }); + + describe('full slide past threshold', () => { + it('activates the right control when swiped left past threshold', () => { + const el = makeSlider(); + + // Enable the right underneath element. + const right = el.querySelector('.Slidable-underneath--right') as HTMLElement; + right.classList.remove('disabled'); + const clickSpy = jest.fn(); + $(right).on('click', clickSpy); + + const content = el.querySelector('.Slidable-content') as HTMLElement; + slidable(el); + + // Swipe 60px to the left — past the 50px threshold. + triggerTouch(content, 'touchstart', 100, 200); + triggerTouch(content, 'touchmove', 40, 200); + triggerTouch(content, 'touchend', 40, 200); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('activates the left control when swiped right past threshold', () => { + const el = makeSlider(); + + // Enable the left underneath element. + const left = el.querySelector('.Slidable-underneath--left') as HTMLElement; + left.classList.remove('disabled'); + const clickSpy = jest.fn(); + $(left).on('click', clickSpy); + + const content = el.querySelector('.Slidable-content') as HTMLElement; + slidable(el); + + // Swipe 60px to the right — past the 50px threshold. + triggerTouch(content, 'touchstart', 100, 200); + triggerTouch(content, 'touchmove', 160, 200); + triggerTouch(content, 'touchend', 160, 200); + + expect(clickSpy).toHaveBeenCalled(); + }); + }); + + describe('vertical scroll (not a slide)', () => { + it('does not call animate when touch moves vertically (page scroll)', () => { + const el = makeSlider(); + const content = el.querySelector('.Slidable-content') as HTMLElement; + + slidable(el); + + const animateSpy = $.fn.animate as jest.MockedFunction; + animateSpy.mockClear(); + + // Move more vertically than horizontally — treated as page scroll, not a slide. + triggerTouch(content, 'touchstart', 100, 200); + triggerTouch(content, 'touchmove', 102, 250); + triggerTouch(content, 'touchend', 102, 250); + + expect(animateSpy).not.toHaveBeenCalled(); + }); + }); + + describe('reset()', () => { + it('returns a reset function that animates the slider back to zero', () => { + const el = makeSlider(); + + const { reset } = slidable(el); + + const animateSpy = $.fn.animate as jest.MockedFunction; + animateSpy.mockClear(); + + reset(); + + expect(animateSpy).toHaveBeenCalled(); + }); + }); +});