Files
privacy.sexy/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts
undergroundwires 728584240c Fix touch, cursor and accessibility in slider
This commit improves the horizontal slider between the generated code
area and the script list. It enhances interaction, accessibility and
performance. It provides missing touch responsiveness, improves
accessibility by using better HTML semantics, introduces throttling and
refactors cursor handling during drag operations with added tests.

These changes provides smoother user experience, better support for
touch devices, reduce load during interactions and ensure the
component's behavior is intuitive and accessible across different
devices and interactions.

- Fix horizontal slider not responding to touch events.
- Improve slider handle to be a `<button>` for improved accessibility
  and native browser support, improving user interaction and keyboard
  support.
- Add throttling in the slider for performance optimization, reducing
  processing load during actions.
- Fix losing dragging state cursor on hover over page elements such as
  input boxes and buttons during dragging.
- Separate dragging logic into its own compositional hook for clearer
  separation of concerns.
- Refactor global cursor mutation process.
- Increase robustness in global cursor changes by preserving and
  restoring previous cursor style to prevent potential side-effects.
- Use Vue 3.2 feature for defining cursor CSS style in `<style>`
  section.
- Expand unit test coverage for horizontal slider, use MouseEvent and
  type cast it to PointerEvent as MouseEvent is not yet supported by
  `jsdom` (see jsdom/jsdom#2527).
2024-01-08 23:08:10 +01:00

270 lines
9.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { ref, Ref } from 'vue';
import { DragDomModifier, useDragHandler } from '@/presentation/components/Scripts/Slider/UseDragHandler';
import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub';
import { throttle } from '@/application/Common/Timing/Throttle';
import { ConstructorArguments } from '@/TypeHelpers';
describe('useDragHandler', () => {
describe('initially', () => {
it('sets displacement X to 0', () => {
// arrange
const expectedValue = 0;
// act
const { displacementX } = initializeDragHandlerWithMocks();
// assert
expect(displacementX.value).to.equal(expectedValue);
});
it('sets dragging state to false', () => {
// arrange
const expectedValue = false;
// act
const { isDragging } = initializeDragHandlerWithMocks();
// assert
expect(isDragging.value).to.equal(expectedValue);
});
it('disables element touch action', () => {
// arrange
const expectedTouchAction = 'none';
const mockElement = document.createElement('div');
// act
initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
});
// assert
const actualTouchAction = mockElement.style.touchAction;
expect(actualTouchAction).to.equal(expectedTouchAction);
});
it('attaches event listener for drag start', () => {
// arrange
const expectedEventName: keyof HTMLElementEventMap = 'pointerdown';
let actualEventName: keyof HTMLElementEventMap | undefined;
const mockElement = document.createElement('div');
mockElement.addEventListener = (eventName: keyof HTMLElementEventMap) => {
actualEventName = eventName;
};
// act
initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
});
// assert
expect(expectedEventName).to.equal(actualEventName);
});
});
describe('on drag', () => {
it('activates dragging on start of drag', () => {
// arrange
const expectedDragState = true;
const mockElement = document.createElement('div');
// act
const { isDragging } = initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
});
mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 }));
// assert
expect(isDragging.value).to.equal(expectedDragState);
});
it('updates displacement during dragging', () => {
// arrange
const initialDragX = 100;
const finalDragX = 150;
const expectedDisplacementX = finalDragX - initialDragX;
const mockElement = document.createElement('div');
const dragDomModifierMock = new DragDomModifierMock();
// act
const { displacementX } = initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
dragDomModifier: dragDomModifierMock,
});
mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 }));
dragDomModifierMock.simulateEvent('pointermove', createMockPointerEvent('pointermove', { clientX: finalDragX }));
// assert
expect(displacementX.value).to.equal(expectedDisplacementX);
});
it('attaches event listeners', () => {
// arrange
const expectedEventNames: ReadonlyArray<keyof HTMLElementEventMap> = [
'pointerup',
'pointermove',
];
const mockElement = document.createElement('div');
const dragDomModifierMock = new DragDomModifierMock();
// act
initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
dragDomModifier: dragDomModifierMock,
});
mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 }));
// assert
const actualEventNames = [...dragDomModifierMock.events].map(([eventName]) => eventName);
expect(expectedEventNames).to.have.lengthOf(actualEventNames.length);
expect(expectedEventNames).to.have.members(actualEventNames);
});
describe('throttling', () => {
it('initializes event throttling', () => {
// arrange
const throttleStub = new ThrottleStub()
.withImmediateExecution(false);
const mockElement = document.createElement('div');
// act
initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
throttler: throttleStub.func,
});
// assert
expect(throttleStub.throttleInitializationCallArgs.length).to.equal(1);
});
it('sets a specific throttle time interval', () => {
// arrange
const expectedThrottleInMs = 15;
const throttleStub = new ThrottleStub()
.withImmediateExecution(false);
const mockElement = document.createElement('div');
// act
initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
throttler: throttleStub.func,
});
// assert
expect(throttleStub.throttleInitializationCallArgs.length).to.equal(1);
const [, actualThrottleInMs] = throttleStub.throttleInitializationCallArgs[0];
expect(expectedThrottleInMs).to.equal(actualThrottleInMs);
});
it('limits frequency of drag movement updates', () => {
// arrange
const expectedTotalThrottledEvents = 3;
const throttleStub = new ThrottleStub();
const mockElement = document.createElement('div');
const dragDomModifierMock = new DragDomModifierMock();
// act
initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
throttler: throttleStub.func,
dragDomModifier: dragDomModifierMock,
});
mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 }));
for (let i = 0; i < expectedTotalThrottledEvents; i++) {
dragDomModifierMock.simulateEvent('pointermove', createMockPointerEvent('pointermove', { clientX: 110 }));
}
// assert
const actualTotalThrottledEvents = throttleStub.throttledFunctionCallArgs.length;
expect(actualTotalThrottledEvents).to.equal(expectedTotalThrottledEvents);
});
it('calculates displacement considering throttling', () => {
// arrange
const throttleStub = new ThrottleStub().withImmediateExecution(true);
const mockElement = document.createElement('div');
const initialDragX = 100;
const firstDisplacementX = 10;
const dragDomModifierMock = new DragDomModifierMock();
// act
const { displacementX } = initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
throttler: throttleStub.func,
dragDomModifier: dragDomModifierMock,
});
mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: initialDragX }));
dragDomModifierMock.simulateEvent('pointermove', createMockPointerEvent('pointermove', { clientX: initialDragX - firstDisplacementX }));
dragDomModifierMock.simulateEvent('pointermove', createMockPointerEvent('pointermove', { clientX: initialDragX - firstDisplacementX * 2 }));
throttleStub.executeFirst();
// assert
expect(displacementX.value).to.equal(firstDisplacementX);
});
});
});
describe('on drag end', () => {
it('deactivates dragging on drag end', () => {
// arrange
const expectedDraggingState = false;
const mockElement = document.createElement('div');
const dragDomModifierMock = new DragDomModifierMock();
// act
const { isDragging } = initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
dragDomModifier: dragDomModifierMock,
});
mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 }));
dragDomModifierMock.simulateEvent('pointerup', createMockPointerEvent('pointerup'));
// assert
expect(isDragging.value).to.equal(expectedDraggingState);
});
it('removes event listeners', () => {
// arrange
const mockElement = document.createElement('div');
const dragDomModifierMock = new DragDomModifierMock();
// act
initializeDragHandlerWithMocks({
draggableElementRef: ref(mockElement),
dragDomModifier: dragDomModifierMock,
});
mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 }));
dragDomModifierMock.simulateEvent('pointerup', createMockPointerEvent('pointerup'));
// assert
const actualEvents = [...dragDomModifierMock.events];
expect(actualEvents).to.have.lengthOf(0);
});
});
});
function initializeDragHandlerWithMocks(mocks?: {
readonly dragDomModifier?: DragDomModifier;
readonly draggableElementRef?: Ref<HTMLElement>;
readonly throttler?: typeof throttle,
}) {
return useDragHandler(
mocks?.draggableElementRef ?? ref(document.createElement('div')),
mocks?.dragDomModifier ?? new DragDomModifierMock(),
mocks?.throttler ?? new ThrottleStub().withImmediateExecution(true).func,
);
}
function createMockPointerEvent(...args: ConstructorArguments<typeof PointerEvent>): PointerEvent {
return new MouseEvent(...args) as PointerEvent; // JSDom does not support `PointerEvent` constructor, https://github.com/jsdom/jsdom/issues/2527
}
class DragDomModifierMock implements DragDomModifier {
public events = new Map<keyof DocumentEventMap, EventListener>();
public addEventListenerToDocument(type: keyof DocumentEventMap, handler: EventListener): void {
this.events.set(type, handler);
}
public removeEventListenerFromDocument(type: keyof DocumentEventMap): void {
this.events.delete(type);
}
public simulateEvent(type: keyof DocumentEventMap, event: Event) {
const handler = this.events.get(type);
if (!handler) {
throw new Error(`No event handler registered for: ${type}`);
}
handler(event);
}
}