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:
undergroundwires
2024-01-08 23:08:10 +01:00
parent 3b1a89ce86
commit 728584240c
8 changed files with 587 additions and 36 deletions

View File

@@ -1,7 +1,7 @@
import { Timer, TimeoutType } from './Timer';
import { PlatformTimer } from './PlatformTimer';
export type CallbackType = (..._: unknown[]) => void;
export type CallbackType = (..._: readonly unknown[]) => void;
export function throttle(
callback: CallbackType,

View File

@@ -1,8 +1,8 @@
<template>
<div
<button
ref="handleElementRef"
class="handle"
:style="{ cursor: cursorCssValue }"
@mousedown="startResize"
type="button"
>
<div class="line" />
<AppIcon
@@ -10,12 +10,14 @@
icon="left-right"
/>
<div class="line" />
</div>
</button>
</template>
<script lang="ts">
import { defineComponent, onUnmounted } from 'vue';
import { defineComponent, shallowRef, watch } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { useDragHandler } from './UseDragHandler';
import { useGlobalCursor } from './UseGlobalCursor';
export default defineComponent({
components: {
@@ -28,39 +30,21 @@ export default defineComponent({
},
setup(_, { emit }) {
const cursorCssValue = 'ew-resize';
let initialX: number | undefined;
const resize = (event: MouseEvent) => {
if (initialX === undefined) {
throw new Error('Resize action started without an initial X coordinate.');
}
const displacementX = event.clientX - initialX;
emit('resized', displacementX);
initialX = event.clientX;
};
const handleElementRef = shallowRef<HTMLElement | undefined>();
const stopResize = () => {
document.body.style.removeProperty('cursor');
document.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResize);
};
const { displacementX, isDragging } = useDragHandler(handleElementRef);
function startResize(event: MouseEvent): void {
initialX = event.clientX;
document.body.style.setProperty('cursor', cursorCssValue);
document.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResize);
event.stopPropagation();
event.preventDefault();
}
useGlobalCursor(isDragging, cursorCssValue);
onUnmounted(() => {
stopResize();
watch(displacementX, (value) => {
emit('resized', value);
});
return {
handleElementRef,
isDragging,
cursorCssValue,
startResize,
};
},
});
@@ -71,12 +55,11 @@ export default defineComponent({
$color : $color-primary-dark;
$color-hover : $color-primary;
$cursor : v-bind(cursorCssValue);
.handle {
@include clickable($cursor: 'ew-resize');
display: flex;
flex-direction: column;
align-items: center;
@include reset-button;
@include clickable($cursor: $cursor);
@include hover-or-touch {
.line {
background: $color-hover;
@@ -85,6 +68,11 @@ $color-hover : $color-primary;
color: $color-hover;
}
}
cursor: $cursor;
display: flex;
flex-direction: column;
align-items: center;
.line {
flex: 1;
background: $color;

View File

@@ -0,0 +1,90 @@
import {
onUnmounted, ref, shallowReadonly, watch,
} from 'vue';
import { throttle } from '@/application/Common/Timing/Throttle';
import type { Ref } from 'vue';
const ThrottleInMs = 15;
export function useDragHandler(
draggableElementRef: Readonly<Ref<HTMLElement | undefined>>,
dragDomModifier: DragDomModifier = new GlobalDocumentDragDomModifier(),
throttler = throttle,
) {
const displacementX = ref(0);
const isDragging = ref(false);
let initialPointerX: number | undefined;
const onDrag = throttler((event: PointerEvent) => {
if (initialPointerX === undefined) {
throw new Error('Resize action started without an initial X coordinate.');
}
displacementX.value = event.clientX - initialPointerX;
initialPointerX = event.clientX;
}, ThrottleInMs);
const stopDrag = () => {
isDragging.value = false;
dragDomModifier.removeEventListenerFromDocument('pointermove', onDrag);
dragDomModifier.removeEventListenerFromDocument('pointerup', stopDrag);
};
const startDrag = (event: PointerEvent) => {
isDragging.value = true;
initialPointerX = event.clientX;
dragDomModifier.addEventListenerToDocument('pointermove', onDrag);
dragDomModifier.addEventListenerToDocument('pointerup', stopDrag);
event.stopPropagation();
event.preventDefault();
};
watch(draggableElementRef, (element) => {
if (!element) {
initialPointerX = undefined;
return;
}
initializeElement(element);
}, { immediate: true });
function initializeElement(element: HTMLElement) {
element.style.touchAction = 'none'; // Disable default touch behavior, necessary for resizing functionality to work correctly on touch-enabled devices
element.addEventListener('pointerdown', startDrag);
}
onUnmounted(() => {
stopDrag();
});
return {
displacementX: shallowReadonly(displacementX),
isDragging: shallowReadonly(isDragging),
};
}
export interface DragDomModifier {
addEventListenerToDocument(
type: keyof DocumentEventMap,
handler: EventListener,
): void;
removeEventListenerFromDocument(
type: keyof DocumentEventMap,
handler: EventListener,
): void;
}
class GlobalDocumentDragDomModifier implements DragDomModifier {
public addEventListenerToDocument(
type: keyof DocumentEventMap,
listener: EventListener,
): void {
document.addEventListener(type, listener);
}
public removeEventListenerFromDocument(
type: keyof DocumentEventMap,
listener: EventListener,
): void {
document.removeEventListener(type, listener);
}
}

View File

@@ -0,0 +1,52 @@
import { watch, type Ref, onUnmounted } from 'vue';
export function useGlobalCursor(
isActive: Readonly<Ref<boolean>>,
cursorCssValue: string,
documentAccessor: CursorStyleDomModifier = new GlobalDocumentCursorStyleDomModifier(),
) {
const cursorStyle = createCursorStyle(cursorCssValue, documentAccessor);
watch(isActive, (isCursorVisible) => {
if (isCursorVisible) {
documentAccessor.appendStyleToHead(cursorStyle);
} else {
documentAccessor.removeElement(cursorStyle);
}
});
onUnmounted(() => {
documentAccessor.removeElement(cursorStyle);
});
}
function createCursorStyle(
cursorCssValue: string,
documentAccessor: CursorStyleDomModifier,
): HTMLStyleElement {
// Using `document.body.style.cursor` does not override cursor when hovered on input boxes,
// buttons etc. so we create a custom style that will do that
const cursorStyle = documentAccessor.createStyleElement();
cursorStyle.innerHTML = `*{cursor: ${cursorCssValue}!important;}`;
return cursorStyle;
}
export interface CursorStyleDomModifier {
appendStyleToHead(element: HTMLStyleElement): void;
removeElement(element: HTMLStyleElement): void;
createStyleElement(): HTMLStyleElement;
}
class GlobalDocumentCursorStyleDomModifier implements CursorStyleDomModifier {
public appendStyleToHead(element: HTMLStyleElement): void {
document.head.appendChild(element);
}
public removeElement(element: HTMLStyleElement): void {
element.remove();
}
public createStyleElement(): HTMLStyleElement {
return document.createElement('style');
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -10,7 +10,7 @@ export class BatchedDebounceStub<T> {
public func = (
callback: (batches: readonly T[]) => void,
waitInMs: number,
): (arg: T) => void => {
): ReturnType<typeof batchedDebounce> => {
this.callHistory.push([callback, waitInMs]);
return (arg: T) => {
this.collectedArgs.push(arg);

View File

@@ -0,0 +1,34 @@
import { CallbackType, throttle } from '@/application/Common/Timing/Throttle';
export class ThrottleStub {
public readonly throttleInitializationCallArgs: Array<Parameters<typeof throttle>> = [];
public readonly throttledFunctionCallArgs = new Array<readonly unknown[]>();
private executeImmediately: boolean = false;
public func = (callback: CallbackType, waitInMs: number): ReturnType<typeof throttle> => {
this.throttleInitializationCallArgs.push([callback, waitInMs]);
return (...args: readonly unknown[]) => {
this.throttledFunctionCallArgs.push([...args]);
if (this.executeImmediately) {
callback(...args);
}
};
};
public withImmediateExecution(executeImmediately: boolean): this {
this.executeImmediately = executeImmediately;
return this;
}
public executeFirst() {
if (this.throttledFunctionCallArgs.length === 0) {
throw new Error('Function was never throttled.');
}
const firstArgs = this.throttledFunctionCallArgs[0];
this.throttleInitializationCallArgs.forEach(([callback]) => {
callback(...firstArgs);
});
}
}