import WebComponentBase from '../../components/web-component-base/web-component-base.js'; import { MixinBase } from '../common/mixin-base.js'; import { PointerCoordinator } from '../common/pointer-coordinator.js'; // SwipeableMixin - declarative web component for swipe gesture detection // Usage: // //
Swipe me!
//
class SwipeableMixin extends MixinBase(WebComponentBase) { [key: string]: any; static get observedAttributes() { return ["min-swipe-distance", "max-swipe-time"]; } constructor() { super(); this._touchStartX = 0; this._touchStartY = 0; this._touchEndX = 0; this._touchEndY = 0; this._touchStartTime = 0; this._minSwipeDistance = 30; this._maxSwipeTime = 800; // simple shadow that just renders children "as-is" this.attachShadow({ mode: 'open' }); const shadowRoot = this.shadowRoot; if (!shadowRoot) { throw new Error('Failed to attach shadow root'); } shadowRoot.innerHTML = ` `; // Pointer event state this._pointerDown = false; this._pointerId = null; } // Private utility functions _validateDistance(distance: string | null) { const num = parseInt(distance ?? ""); return !isNaN(num) && num > 0; } _validateTime(time: string | null) { const num = parseInt(time ?? ""); return !isNaN(num) && num > 0; } _getTouchCoordinates(event: PointerEvent | MouseEvent | TouchEvent) { if ('touches' in event && event.touches && event.touches.length > 0) { // Touch start or move const touch = event.touches[0]; return { x: touch.clientX, y: touch.clientY }; } else if ('changedTouches' in event && event.changedTouches && event.changedTouches.length > 0) { // Touch end const touch = event.changedTouches[0]; return { x: touch.clientX, y: touch.clientY }; } else { // Mouse event fallback return { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY }; } } _calculateSwipeDistance() { const deltaX = this._touchEndX - this._touchStartX; const deltaY = this._touchEndY - this._touchStartY; return { deltaX, deltaY, distance: Math.sqrt(deltaX * deltaX + deltaY * deltaY), }; } _calculateSwipeTime() { return Date.now() - this._touchStartTime; } _determineSwipeDirection(deltaX: number, deltaY: number) { const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); if (absX > absY) { return deltaX > 0 ? "right" : "left"; } else { return deltaY > 0 ? "down" : "up"; } } _isValidSwipe(distance: number, time: number) { return distance >= this._minSwipeDistance && time <= this._maxSwipeTime; } _handleTouchStart = (event: PointerEvent | MouseEvent) => { event.preventDefault(); const coords = this._getTouchCoordinates(event); this._touchStartX = coords.x; this._touchStartY = coords.y; this._touchStartTime = Date.now(); }; _handleTouchMove = (event: PointerEvent | MouseEvent) => { event.preventDefault(); // Prevent scrolling during swipe }; _handleTouchEnd = (event: PointerEvent | MouseEvent) => { event.preventDefault(); const coords = this._getTouchCoordinates(event); this._touchEndX = coords.x; this._touchEndY = coords.y; const { deltaX, deltaY, distance } = this._calculateSwipeDistance(); const time = this._calculateSwipeTime(); if (this._isValidSwipe(distance, time)) { const direction = this._determineSwipeDirection(deltaX, deltaY); this.onSwipe(direction, { deltaX, deltaY, distance, time }); } }; _handleMouseStart = (event: PointerEvent | MouseEvent) => { const coords = this._getTouchCoordinates(event); this._touchStartX = coords.x; this._touchStartY = coords.y; this._touchStartTime = Date.now(); }; _handleMouseEnd = (event: PointerEvent | MouseEvent) => { const coords = this._getTouchCoordinates(event); this._touchEndX = coords.x; this._touchEndY = coords.y; const { deltaX, deltaY, distance } = this._calculateSwipeDistance(); const time = this._calculateSwipeTime(); if (this._isValidSwipe(distance, time)) { const direction = this._determineSwipeDirection(deltaX, deltaY); this.onSwipe(direction, { deltaX, deltaY, distance, time }); } }; // Public API setMinSwipeDistance(distance: string) { if (this._validateDistance(distance)) { this._minSwipeDistance = parseInt(distance); } } setMaxSwipeTime(time: string) { if (this._validateTime(time)) { this._maxSwipeTime = parseInt(time); } } onSwipe(direction: string, details: Record) { // Always dispatch from the host element, not from shadowRoot or a child this.dispatchEvent(new CustomEvent("swipe", { detail: { direction, ...details }, bubbles: true, composed: true, })); } connectedCallback() { super.connectedCallback(); // Set initial attributes const distance = this.getAttribute("min-swipe-distance"); if (this._validateDistance(distance)) { this._minSwipeDistance = parseInt(distance ?? ""); } const time = this.getAttribute("max-swipe-time"); if (this._validateTime(time)) { this._maxSwipeTime = parseInt(time ?? ""); } // Attach pointer events to the host element this.addEventListener("pointerdown", this._handlePointerDown); this.addEventListener("pointermove", this._handlePointerMove); this.addEventListener("pointerup", this._handlePointerUp); this.addEventListener("pointercancel", this._handlePointerUp); this.addEventListener("pointerleave", this._handlePointerUp); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("pointerdown", this._handlePointerDown); this.removeEventListener("pointermove", this._handlePointerMove); this.removeEventListener("pointerup", this._handlePointerUp); this.removeEventListener("pointercancel", this._handlePointerUp); this.removeEventListener("pointerleave", this._handlePointerUp); } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { super.attributeChangedCallback(name, oldValue, newValue); if (name === "min-swipe-distance" && this._validateDistance(newValue)) { this._minSwipeDistance = parseInt(newValue ?? ""); } else if (name === "max-swipe-time" && this._validateTime(newValue)) { this._maxSwipeTime = parseInt(newValue ?? ""); } } _handlePointerDown = (event: PointerEvent) => { if (this._pointerDown) return; // Only track one pointer // Try to capture the pointer (only for non-redispatched events) if (!PointerCoordinator.isRedispatchedEvent(event)) { if (!PointerCoordinator.capturePointer(this, event.pointerId)) { return; // Another mixin captured it, we'll listen for redispatched events } // Redispatch the event so other mixins can receive it PointerCoordinator.redispatchPointerEvent(this, event); } // Process the event (only if we captured it OR if it's a redispatched event from another element) if (PointerCoordinator.isRedispatchedEvent(event) || PointerCoordinator.hasPointerCapture(this, event.pointerId)) { // If we captured the pointer, only process direct events (not redispatched ones) if (PointerCoordinator.hasPointerCapture(this, event.pointerId) && PointerCoordinator.isRedispatchedEvent(event)) { return; } this._pointerDown = true; this._pointerId = event.pointerId; this._touchStartX = event.clientX; this._touchStartY = event.clientY; this._touchStartTime = Date.now(); } }; _handlePointerMove = (event: PointerEvent) => { if (!this._pointerDown || event.pointerId !== this._pointerId) return; // Only process if we captured the pointer or it's a redispatched event from another element if (!PointerCoordinator.isRedispatchedEvent(event) && !PointerCoordinator.hasPointerCapture(this, event.pointerId)) { return; } // If we captured the pointer, only process direct events (not redispatched ones) if (PointerCoordinator.hasPointerCapture(this, event.pointerId) && PointerCoordinator.isRedispatchedEvent(event)) { return; } // Redispatch the event (only for non-redispatched events) if (!PointerCoordinator.isRedispatchedEvent(event)) { PointerCoordinator.redispatchPointerEvent(this, event); } // Calculate current drag distance and direction const deltaX = event.clientX - this._touchStartX; const deltaY = event.clientY - this._touchStartY; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); // Only prevent scrolling if we're actually processing a gesture if (PointerCoordinator.shouldProcessGesture(deltaX, deltaY, this._minSwipeDistance / 2)) { event.preventDefault(); } // Emit drag event for real-time feedback this.dispatchEvent(new CustomEvent("drag", { detail: { deltaX, deltaY, distance, direction: Math.abs(deltaX) > Math.abs(deltaY) ? (deltaX > 0 ? "right" : "left") : (deltaY > 0 ? "down" : "up") }, bubbles: true, composed: true, })); }; _handlePointerUp = (event: PointerEvent) => { if (!this._pointerDown || event.pointerId !== this._pointerId) return; // Only process if we captured the pointer or it's a redispatched event from another element if (!PointerCoordinator.isRedispatchedEvent(event) && !PointerCoordinator.hasPointerCapture(this, event.pointerId)) { return; } // If we captured the pointer, only process direct events (not redispatched ones) if (PointerCoordinator.hasPointerCapture(this, event.pointerId) && PointerCoordinator.isRedispatchedEvent(event)) { return; } // Redispatch the event (only for non-redispatched events) if (!PointerCoordinator.isRedispatchedEvent(event)) { PointerCoordinator.redispatchPointerEvent(this, event); } this._pointerDown = false; PointerCoordinator.releasePointer(this, this._pointerId); this._touchEndX = event.clientX; this._touchEndY = event.clientY; const deltaX = this._touchEndX - this._touchStartX; const deltaY = this._touchEndY - this._touchStartY; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); const time = Date.now() - this._touchStartTime; if (distance >= this._minSwipeDistance && time <= this._maxSwipeTime) { const direction = Math.abs(deltaX) > Math.abs(deltaY) ? (deltaX > 0 ? "right" : "left") : (deltaY > 0 ? "down" : "up"); this.onSwipe(direction, { deltaX, deltaY, distance, time }); } }; } export { SwipeableMixin };