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).
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import {
|
||||
Ref, ref, defineComponent, nextTick,
|
||||
} from 'vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { CursorStyleDomModifier, useGlobalCursor } from '@/presentation/components/Scripts/Slider/UseGlobalCursor';
|
||||
|
||||
describe('useGlobalCursor', () => {
|
||||
it('adds cursor style to head on activation', async () => {
|
||||
// arrange
|
||||
const expectedCursorCssStyleValue = 'pointer';
|
||||
const isActive = ref(false);
|
||||
|
||||
// act
|
||||
const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive });
|
||||
isActive.value = true;
|
||||
await nextTick();
|
||||
|
||||
// assert
|
||||
const { elementsAppendedToHead: appendedElements } = cursorStyleDomModifierMock;
|
||||
expect(appendedElements.length).to.equal(1);
|
||||
expect(appendedElements[0].innerHTML).toContain(expectedCursorCssStyleValue);
|
||||
});
|
||||
|
||||
it('removes cursor style from head on deactivation', async () => {
|
||||
// arrange
|
||||
const isActive = ref(true);
|
||||
|
||||
// act
|
||||
const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive });
|
||||
await nextTick();
|
||||
isActive.value = false;
|
||||
await nextTick();
|
||||
|
||||
// assert
|
||||
expect(cursorStyleDomModifierMock.elementsRemovedFromHead.length).to.equal(1);
|
||||
});
|
||||
|
||||
it('cleans up cursor style on unmount', async () => {
|
||||
// arrange
|
||||
const isActive = ref(true);
|
||||
|
||||
// act
|
||||
const { wrapper, returnObject } = mountWrapperComponent({ isActive });
|
||||
wrapper.unmount();
|
||||
await nextTick();
|
||||
|
||||
// assert
|
||||
const { cursorStyleDomModifierMock } = returnObject;
|
||||
expect(cursorStyleDomModifierMock.elementsRemovedFromHead.length).to.equal(1);
|
||||
});
|
||||
|
||||
it('does not append style to head when initially inactive', async () => {
|
||||
// arrange
|
||||
const isActive = ref(false);
|
||||
|
||||
// act
|
||||
const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive });
|
||||
await nextTick();
|
||||
|
||||
// assert
|
||||
expect(cursorStyleDomModifierMock.elementsAppendedToHead.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function mountWrapperComponent(...hookOptions: Parameters<typeof createHookWithStubs>) {
|
||||
let returnObject: ReturnType<typeof createHookWithStubs> | undefined;
|
||||
const wrapper = shallowMount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
returnObject = createHookWithStubs(...hookOptions);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
if (!returnObject) {
|
||||
throw new Error('missing hook result');
|
||||
}
|
||||
return {
|
||||
wrapper,
|
||||
returnObject,
|
||||
};
|
||||
}
|
||||
|
||||
function createHookWithStubs(options?: {
|
||||
readonly isActive?: Ref<boolean>;
|
||||
readonly expectedCursorCssStyleValue?: string;
|
||||
}) {
|
||||
const cursorStyleDomModifierMock = new CursorStyleDomModifierStub();
|
||||
const hookResult = useGlobalCursor(
|
||||
options?.isActive ?? ref(true),
|
||||
options?.expectedCursorCssStyleValue ?? 'pointer',
|
||||
cursorStyleDomModifierMock,
|
||||
);
|
||||
return {
|
||||
cursorStyleDomModifierMock,
|
||||
hookResult,
|
||||
};
|
||||
}
|
||||
|
||||
class CursorStyleDomModifierStub implements CursorStyleDomModifier {
|
||||
public elementsAppendedToHead: HTMLStyleElement[] = [];
|
||||
|
||||
public elementsRemovedFromHead: HTMLStyleElement[] = [];
|
||||
|
||||
public appendStyleToHead(element: HTMLStyleElement): void {
|
||||
this.elementsAppendedToHead.push(element);
|
||||
}
|
||||
|
||||
public removeElement(element: HTMLStyleElement): void {
|
||||
this.elementsRemovedFromHead.push(element);
|
||||
}
|
||||
|
||||
public createStyleElement(): HTMLStyleElement {
|
||||
const element = document.createElement('style');
|
||||
return element;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user