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