Skip to content

Commit

Permalink
Fix touch, cursor and accessibility in slider
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
undergroundwires committed Jan 8, 2024
1 parent 3b1a89c commit 7285842
Showing 8 changed files with 587 additions and 36 deletions.
2 changes: 1 addition & 1 deletion src/application/Common/Timing/Throttle.ts
Original file line number Diff line number Diff line change
@@ -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,
56 changes: 22 additions & 34 deletions src/presentation/components/Scripts/Slider/SliderHandle.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
<template>
<div
<button
ref="handleElementRef"
class="handle"
:style="{ cursor: cursorCssValue }"
@mousedown="startResize"
type="button"
>
<div class="line" />
<AppIcon
class="icon"
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;
90 changes: 90 additions & 0 deletions src/presentation/components/Scripts/Slider/UseDragHandler.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
52 changes: 52 additions & 0 deletions src/presentation/components/Scripts/Slider/UseGlobalCursor.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
Loading

0 comments on commit 7285842

Please sign in to comment.