# Touch

The Touch component provides access to touch events generated by touchscreens and other touch-sensitive input devices. It allows web applications to respond to user gestures such as taps, swipes, pinches, rotations, and multi-touch interactions, enabling rich mobile experiences.

This component is particularly useful for:

* Drawing and painting applications
* Gesture-based navigation and controls
* Multi-touch games and interactive experiences
* Image manipulation (pinch-to-zoom, rotation)
* Custom touch-optimized UI components
* Signature capture and handwriting recognition
* Touch-based animations and effects
* Canvas-based interactive applications
* Mobile-first interactive dashboards

## Browser Support

The Touch API is widely supported on all modern mobile browsers and touch-enabled devices. Desktop browsers typically support touch events when used with touch-capable hardware (touchscreen monitors, trackpads with multi-touch support).

**Supported Platforms:**

* iOS Safari: Full support
* Android Chrome: Full support
* Mobile Firefox: Full support
* Desktop browsers with touch hardware: Full support

{% hint style="info" %}
The component automatically detects touch capability. On non-touch devices, the events won't be dispatched.
{% endhint %}

## Usage

### Basic Touch Drawing

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Touch Drawing Pad</h2>
    <canvas {{ stimulus_target('@pwa/touch', 'canvas') }}
            id="drawing-canvas"
            width="600"
            height="400">
    </canvas>
    <button id="clear-btn">Clear</button>
</div>

<script>
    const canvas = document.getElementById('drawing-canvas');
    const ctx = canvas.getContext('2d');

    ctx.strokeStyle = '#3b82f6';
    ctx.lineWidth = 3;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    let isDrawing = false;
    let lastX = 0;
    let lastY = 0;

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        isDrawing = true;
        lastX = touch.clientX - rect.left;
        lastY = touch.clientY - rect.top;
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        if (!isDrawing) return;

        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();
        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        lastX = x;
        lastY = y;
    });

    document.addEventListener('pwa--touch:ended', () => {
        isDrawing = false;
    });

    document.getElementById('clear-btn').addEventListener('click', () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
    });
</script>

<style>
    canvas {
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
        display: block;
        margin-bottom: 15px;
    }

    button {
        padding: 10px 20px;
        background: #ef4444;
        color: white;
        border: none;
        border-radius: 6px;
        cursor: pointer;
    }
</style>
```

{% endcode %}

### Swipe Gesture Detection

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <div id="swipe-area">
        <h2>Swipe Here</h2>
        <p id="swipe-result">Swipe in any direction</p>
    </div>
</div>

<script>
    let touchStartX = 0;
    let touchStartY = 0;
    let touchStartTime = 0;

    const SWIPE_THRESHOLD = 50; // minimum distance in pixels
    const SWIPE_TIME_LIMIT = 500; // maximum time in milliseconds

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        touchStartX = touch.clientX;
        touchStartY = touch.clientY;
        touchStartTime = Date.now();
    });

    document.addEventListener('pwa--touch:ended', (event) => {
        const touch = event.detail;
        const deltaX = touch.clientX - touchStartX;
        const deltaY = touch.clientY - touchStartY;
        const deltaTime = Date.now() - touchStartTime;

        // Check if it's a swipe (fast movement over threshold)
        if (deltaTime > SWIPE_TIME_LIMIT) return;

        const absX = Math.abs(deltaX);
        const absY = Math.abs(deltaY);

        if (absX < SWIPE_THRESHOLD && absY < SWIPE_THRESHOLD) return;

        let direction;
        if (absX > absY) {
            // Horizontal swipe
            direction = deltaX > 0 ? 'Right' : 'Left';
        } else {
            // Vertical swipe
            direction = deltaY > 0 ? 'Down' : 'Up';
        }

        const result = document.getElementById('swipe-result');
        result.textContent = `Swiped ${direction}! (${Math.round(Math.max(absX, absY))}px in ${deltaTime}ms)`;
        result.style.color = '#10b981';

        setTimeout(() => {
            result.textContent = 'Swipe in any direction';
            result.style.color = '';
        }, 2000);
    });
</script>

<style>
    #swipe-area {
        width: 100%;
        max-width: 600px;
        height: 300px;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        border-radius: 12px;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        color: white;
        user-select: none;
        touch-action: none;
    }

    #swipe-result {
        font-size: 18px;
        font-weight: 500;
    }
</style>
```

{% endcode %}

### Pinch-to-Zoom

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Pinch to Zoom</h2>

    <div id="zoom-container">
        <img id="zoom-image"
             src="/images/photo.jpg"
             alt="Zoomable image">
    </div>

    <div id="zoom-info">
        <p>Zoom: <span id="zoom-level">100%</span></p>
    </div>
</div>

<script>
    const image = document.getElementById('zoom-image');
    const zoomLevel = document.getElementById('zoom-level');

    let currentScale = 1;
    let initialDistance = 0;
    let initialScale = 1;

    document.addEventListener('pwa--touch:updated', (event) => {
        const { touches } = event.detail;

        // Pinch gesture requires exactly 2 touches
        if (touches.length === 2) {
            const touch1 = touches[0];
            const touch2 = touches[1];

            // Calculate distance between two touch points
            const dx = touch2.clientX - touch1.clientX;
            const dy = touch2.clientY - touch1.clientY;
            const distance = Math.sqrt(dx * dx + dy * dy);

            if (initialDistance === 0) {
                // First frame of pinch
                initialDistance = distance;
                initialScale = currentScale;
            } else {
                // Calculate scale based on distance change
                const scale = (distance / initialDistance) * initialScale;

                // Limit scale between 0.5x and 5x
                currentScale = Math.max(0.5, Math.min(5, scale));

                // Apply scale
                image.style.transform = `scale(${currentScale})`;

                // Update UI
                zoomLevel.textContent = `${Math.round(currentScale * 100)}%`;
            }
        } else {
            // Reset when not pinching
            initialDistance = 0;
        }
    });
</script>

<style>
    #zoom-container {
        width: 100%;
        max-width: 600px;
        height: 400px;
        overflow: hidden;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: #f9fafb;
        touch-action: none;
    }

    #zoom-image {
        max-width: 100%;
        max-height: 100%;
        transition: transform 0.1s ease-out;
        touch-action: none;
    }

    #zoom-info {
        margin-top: 15px;
        padding: 10px;
        background: #f3f4f6;
        border-radius: 6px;
        text-align: center;
    }

    #zoom-level {
        font-weight: bold;
        color: #3b82f6;
    }
</style>
```

{% endcode %}

### Multi-Touch Drawing with Different Colors

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Multi-Touch Paint</h2>
    <p>Use multiple fingers to draw with different colors!</p>

    <canvas id="multi-canvas" width="600" height="400"></canvas>

    <button id="clear-multi">Clear Canvas</button>
</div>

<script>
    const canvas = document.getElementById('multi-canvas');
    const ctx = canvas.getContext('2d');

    const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'];
    const activeTouches = new Map();

    ctx.lineWidth = 4;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        // Assign a color based on touch identifier
        const colorIndex = touch.identifier % colors.length;
        const color = colors[colorIndex];

        activeTouches.set(touch.identifier, {
            color: color,
            lastX: touch.clientX - rect.left,
            lastY: touch.clientY - rect.top
        });
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        const touchData = activeTouches.get(touch.identifier);
        if (!touchData) return;

        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        ctx.strokeStyle = touchData.color;
        ctx.beginPath();
        ctx.moveTo(touchData.lastX, touchData.lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        touchData.lastX = x;
        touchData.lastY = y;
    });

    document.addEventListener('pwa--touch:ended', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);
    });

    document.addEventListener('pwa--touch:cancelled', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);
    });

    document.getElementById('clear-multi').addEventListener('click', () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        activeTouches.clear();
    });
</script>

<style>
    #multi-canvas {
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        display: block;
        margin: 15px 0;
        touch-action: none;
        background: white;
    }
</style>
```

{% endcode %}

### Rotation Gesture

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Two-Finger Rotation</h2>

    <div id="rotation-container">
        <div id="rotatable-box">
            <p>Rotate Me!</p>
        </div>
    </div>

    <div id="rotation-info">
        <p>Rotation: <span id="rotation-angle">0°</span></p>
    </div>
</div>

<script>
    const box = document.getElementById('rotatable-box');
    const angleDisplay = document.getElementById('rotation-angle');

    let currentRotation = 0;
    let initialAngle = 0;
    let startRotation = 0;

    document.addEventListener('pwa--touch:updated', (event) => {
        const { touches } = event.detail;

        if (touches.length === 2) {
            const touch1 = touches[0];
            const touch2 = touches[1];

            // Calculate angle between two touch points
            const dx = touch2.clientX - touch1.clientX;
            const dy = touch2.clientY - touch1.clientY;
            const angle = Math.atan2(dy, dx) * (180 / Math.PI);

            if (initialAngle === null) {
                // First frame
                initialAngle = angle;
                startRotation = currentRotation;
            } else {
                // Calculate rotation delta
                let deltaAngle = angle - initialAngle;

                // Normalize to -180 to 180
                if (deltaAngle > 180) deltaAngle -= 360;
                if (deltaAngle < -180) deltaAngle += 360;

                currentRotation = startRotation + deltaAngle;

                // Apply rotation
                box.style.transform = `rotate(${currentRotation}deg)`;

                // Update display
                angleDisplay.textContent = `${Math.round(currentRotation)}°`;
            }
        } else {
            initialAngle = null;
        }
    });
</script>

<style>
    #rotation-container {
        width: 100%;
        max-width: 600px;
        height: 400px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: #f9fafb;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
    }

    #rotatable-box {
        width: 150px;
        height: 150px;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        border-radius: 12px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: white;
        font-weight: bold;
        font-size: 18px;
        transition: transform 0.1s ease-out;
        user-select: none;
    }

    #rotation-info {
        margin-top: 15px;
        padding: 10px;
        background: #f3f4f6;
        border-radius: 6px;
        text-align: center;
    }

    #rotation-angle {
        font-weight: bold;
        color: #8b5cf6;
    }
</style>
```

{% endcode %}

### Touch-Based Signature Pad

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Signature Pad</h2>

    <div class="signature-container">
        <canvas id="signature-canvas" width="600" height="200"></canvas>
    </div>

    <div class="signature-controls">
        <button id="clear-signature">Clear</button>
        <button id="save-signature">Save Signature</button>
    </div>

    <div id="signature-preview" style="display: none;">
        <h3>Saved Signature:</h3>
        <img id="signature-image" alt="Signature">
    </div>
</div>

<script>
    const canvas = document.getElementById('signature-canvas');
    const ctx = canvas.getContext('2d');
    const preview = document.getElementById('signature-preview');
    const previewImage = document.getElementById('signature-image');

    // Configure canvas for signature
    ctx.strokeStyle = '#1f2937';
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    let isDrawing = false;
    let lastX = 0;
    let lastY = 0;
    let hasContent = false;

    // Draw background
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        isDrawing = true;
        lastX = touch.clientX - rect.left;
        lastY = touch.clientY - rect.top;
        hasContent = true;
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        if (!isDrawing) return;

        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();
        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        // Smooth line drawing
        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        lastX = x;
        lastY = y;
    });

    document.addEventListener('pwa--touch:ended', () => {
        isDrawing = false;
    });

    document.getElementById('clear-signature').addEventListener('click', () => {
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        preview.style.display = 'none';
        hasContent = false;
    });

    document.getElementById('save-signature').addEventListener('click', () => {
        if (!hasContent) {
            alert('Please sign first!');
            return;
        }

        // Convert canvas to image
        const dataURL = canvas.toDataURL('image/png');
        previewImage.src = dataURL;
        preview.style.display = 'block';

        // You could also send this to a server
        console.log('Signature saved:', dataURL);
    });
</script>

<style>
    .signature-container {
        border: 2px dashed #d1d5db;
        border-radius: 8px;
        background: white;
        padding: 10px;
        margin: 15px 0;
    }

    #signature-canvas {
        display: block;
        border: 1px solid #e5e7eb;
        border-radius: 4px;
        touch-action: none;
        cursor: crosshair;
    }

    .signature-controls {
        display: flex;
        gap: 10px;
        margin: 15px 0;
    }

    .signature-controls button {
        padding: 10px 20px;
        border: none;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 500;
    }

    #clear-signature {
        background: #ef4444;
        color: white;
    }

    #save-signature {
        background: #10b981;
        color: white;
    }

    #signature-preview {
        margin-top: 20px;
        padding: 20px;
        background: #f9fafb;
        border-radius: 8px;
    }

    #signature-image {
        max-width: 100%;
        border: 1px solid #e5e7eb;
        border-radius: 4px;
    }
</style>
```

{% endcode %}

### Touch-Based Game (Bubble Pop)

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <div class="game-container">
        <h2>Bubble Pop Game</h2>

        <div class="game-stats">
            <div>Score: <span id="score">0</span></div>
            <div>Time: <span id="timer">30</span>s</div>
        </div>

        <canvas id="game-canvas" width="600" height="400"></canvas>

        <button id="start-game">Start Game</button>
    </div>
</div>

<script>
    const canvas = document.getElementById('game-canvas');
    const ctx = canvas.getContext('2d');
    const scoreEl = document.getElementById('score');
    const timerEl = document.getElementById('timer');
    const startBtn = document.getElementById('start-game');

    let bubbles = [];
    let score = 0;
    let timeLeft = 30;
    let gameActive = false;
    let gameInterval = null;
    let spawnInterval = null;

    class Bubble {
        constructor() {
            this.x = Math.random() * (canvas.width - 60) + 30;
            this.y = canvas.height + 30;
            this.radius = 20 + Math.random() * 20;
            this.speed = 1 + Math.random() * 2;
            this.color = `hsl(${Math.random() * 360}, 70%, 60%)`;
        }

        update() {
            this.y -= this.speed;
        }

        draw() {
            ctx.fillStyle = this.color;
            ctx.beginPath();
            ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
            ctx.fill();

            // Highlight
            ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
            ctx.beginPath();
            ctx.arc(this.x - this.radius / 3, this.y - this.radius / 3, this.radius / 3, 0, Math.PI * 2);
            ctx.fill();
        }

        isOffScreen() {
            return this.y + this.radius < 0;
        }

        containsPoint(x, y) {
            const dx = x - this.x;
            const dy = y - this.y;
            return dx * dx + dy * dy <= this.radius * this.radius;
        }
    }

    function startGame() {
        score = 0;
        timeLeft = 30;
        bubbles = [];
        gameActive = true;

        scoreEl.textContent = score;
        timerEl.textContent = timeLeft;
        startBtn.disabled = true;

        gameInterval = setInterval(() => {
            timeLeft--;
            timerEl.textContent = timeLeft;

            if (timeLeft <= 0) {
                endGame();
            }
        }, 1000);

        spawnInterval = setInterval(() => {
            bubbles.push(new Bubble());
        }, 800);

        gameLoop();
    }

    function endGame() {
        gameActive = false;
        clearInterval(gameInterval);
        clearInterval(spawnInterval);
        startBtn.disabled = false;

        ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        ctx.fillStyle = 'white';
        ctx.font = '48px Arial';
        ctx.textAlign = 'center';
        ctx.fillText(`Game Over!`, canvas.width / 2, canvas.height / 2 - 30);
        ctx.font = '32px Arial';
        ctx.fillText(`Score: ${score}`, canvas.width / 2, canvas.height / 2 + 20);
    }

    function gameLoop() {
        if (!gameActive) return;

        // Clear canvas
        ctx.fillStyle = '#f0f9ff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Update and draw bubbles
        bubbles = bubbles.filter(bubble => {
            bubble.update();
            bubble.draw();
            return !bubble.isOffScreen();
        });

        requestAnimationFrame(gameLoop);
    }

    document.addEventListener('pwa--touch:started', (event) => {
        if (!gameActive) return;

        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();
        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        // Check if touch hit any bubble
        for (let i = bubbles.length - 1; i >= 0; i--) {
            if (bubbles[i].containsPoint(x, y)) {
                // Pop the bubble
                bubbles.splice(i, 1);
                score += 10;
                scoreEl.textContent = score;

                // Visual feedback
                ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
                ctx.beginPath();
                ctx.arc(x, y, 30, 0, Math.PI * 2);
                ctx.fill();

                break;
            }
        }
    });

    startBtn.addEventListener('click', startGame);

    // Draw initial state
    ctx.fillStyle = '#f0f9ff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#6b7280';
    ctx.font = '24px Arial';
    ctx.textAlign = 'center';
    ctx.fillText('Press Start to Play!', canvas.width / 2, canvas.height / 2);
</script>

<style>
    .game-container {
        max-width: 650px;
        margin: 0 auto;
    }

    .game-stats {
        display: flex;
        justify-content: space-around;
        margin: 15px 0;
        padding: 15px;
        background: #f3f4f6;
        border-radius: 8px;
        font-size: 18px;
        font-weight: bold;
    }

    #game-canvas {
        display: block;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
        margin: 15px 0;
        background: #f0f9ff;
    }

    #start-game {
        width: 100%;
        padding: 15px;
        background: #3b82f6;
        color: white;
        border: none;
        border-radius: 6px;
        font-size: 18px;
        font-weight: bold;
        cursor: pointer;
    }

    #start-game:hover:not(:disabled) {
        background: #2563eb;
    }

    #start-game:disabled {
        opacity: 0.5;
        cursor: not-allowed;
    }
</style>
```

{% endcode %}

## Parameters

None

## Actions

None - This component automatically monitors touch events on the controller element.

## Targets

### `canvas`

Optional target for canvas elements. While not required, using this target ensures proper touch event handling on canvas elements.

```twig
<canvas {{ stimulus_target('@pwa/touch', 'canvas') }}>
</canvas>
```

## Events

### `pwa--touch:started`

Dispatched when a new touch contact is detected (finger touches the screen). Can fire multiple times if multiple fingers touch simultaneously.

**Payload**: Native Touch object

Properties include:

* `identifier` (number): Unique identifier for this touch point
* `clientX` (number): X coordinate relative to viewport
* `clientY` (number): Y coordinate relative to viewport
* `pageX` (number): X coordinate relative to document
* `pageY` (number): Y coordinate relative to document
* `screenX` (number): X coordinate relative to screen
* `screenY` (number): Y coordinate relative to screen
* `radiusX` (number): X radius of contact area
* `radiusY` (number): Y radius of contact area
* `rotationAngle` (number): Rotation angle of contact area
* `force` (number): Pressure of touch (0-1, if supported)

Example:

```javascript
document.addEventListener('pwa--touch:started', (event) => {
    const touch = event.detail;
    console.log(`Touch started at (${touch.clientX}, ${touch.clientY})`);
    console.log('Touch ID:', touch.identifier);
    console.log('Pressure:', touch.force);
});
```

### `pwa--touch:moved`

Dispatched when a touch contact moves across the screen.

**Payload**: Native Touch object (same properties as `started`)

Example:

```javascript
document.addEventListener('pwa--touch:moved', (event) => {
    const touch = event.detail;
    console.log(`Touch moved to (${touch.clientX}, ${touch.clientY})`);
});
```

### `pwa--touch:ended`

Dispatched when a touch contact ends (finger lifts from screen).

**Payload**: Native Touch object (same properties as `started`)

Example:

```javascript
document.addEventListener('pwa--touch:ended', (event) => {
    const touch = event.detail;
    console.log(`Touch ended at (${touch.clientX}, ${touch.clientY})`);
});
```

### `pwa--touch:cancelled`

Dispatched when a touch contact is canceled by the browser or OS. This can happen due to:

* Context switches (app switching, notifications)
* Gesture interruptions (system gestures)
* Too many simultaneous touches
* Touch moved outside tracking area

**Payload**: Native Touch object (same properties as `started`)

Example:

```javascript
document.addEventListener('pwa--touch:cancelled', (event) => {
    const touch = event.detail;
    console.log('Touch cancelled:', touch.identifier);
    // Clean up any ongoing operations for this touch
});
```

### `pwa--touch:updated`

Dispatched after each native touch event, once per DOM event cycle. Provides a complete snapshot of all active touches managed by the controller. This is particularly useful for multi-touch gestures like pinch-zoom and rotation that require tracking multiple touch points simultaneously.

**Payload**: `{touches: Array<TouchData>}`

Each touch in the array contains:

* `identifier` (number): Unique touch identifier
* `clientX` (number): X coordinate relative to viewport
* `clientY` (number): Y coordinate relative to viewport
* `pageX` (number): X coordinate relative to document
* `pageY` (number): Y coordinate relative to document
* `screenX` (number): X coordinate relative to screen
* `screenY` (number): Y coordinate relative to screen
* `radiusX` (number): X radius of contact area
* `radiusY` (number): Y radius of contact area
* `force` (number): Pressure of touch
* `rotationAngle` (number): Rotation angle of contact area
* `top` (number): Y coordinate (alias for clientY)
* `left` (number): X coordinate (alias for clientX)

Example:

```javascript
document.addEventListener('pwa--touch:updated', (event) => {
    const { touches } = event.detail;

    console.log(`Active touches: ${touches.length}`);

    if (touches.length === 2) {
        // Pinch gesture - calculate distance
        const dx = touches[1].clientX - touches[0].clientX;
        const dy = touches[1].clientY - touches[0].clientY;
        const distance = Math.sqrt(dx * dx + dy * dy);
        console.log('Pinch distance:', distance);
    }
});
```

## Best Practices

1. **Prevent default scrolling**: Use `touch-action: none` CSS on touch-interactive elements
2. **Handle cancellation**: Always handle `cancelled` events to clean up state
3. **Track touch identifiers**: Use `identifier` to track individual touches in multi-touch scenarios
4. **Optimize performance**: Minimize work in touch event handlers, especially for `moved`
5. **Provide visual feedback**: Show immediate visual response to touch interactions
6. **Consider touch size**: Design touch targets at least 44x44 pixels
7. **Test on devices**: Touch behavior varies between devices - test on real hardware
8. **Avoid hover-based UI**: Touch devices don't have hover - design accordingly
9. **Handle orientation changes**: Account for device rotation
10. **Be mindful of gestures**: Some gestures may conflict with system gestures

## Gesture Detection Patterns

### Tap Detection

```javascript
let tapStartTime = 0;
let tapStartX = 0;
let tapStartY = 0;

const TAP_TIME_LIMIT = 200; // ms
const TAP_MOVE_THRESHOLD = 10; // px

document.addEventListener('pwa--touch:started', (event) => {
    tapStartTime = Date.now();
    tapStartX = event.detail.clientX;
    tapStartY = event.detail.clientY;
});

document.addEventListener('pwa--touch:ended', (event) => {
    const duration = Date.now() - tapStartTime;
    const dx = event.detail.clientX - tapStartX;
    const dy = event.detail.clientY - tapStartY;
    const distance = Math.sqrt(dx * dx + dy * dy);

    if (duration < TAP_TIME_LIMIT && distance < TAP_MOVE_THRESHOLD) {
        console.log('Tap detected!');
    }
});
```

### Long Press Detection

```javascript
let longPressTimer = null;
const LONG_PRESS_DURATION = 500; // ms

document.addEventListener('pwa--touch:started', (event) => {
    const touch = event.detail;

    longPressTimer = setTimeout(() => {
        console.log('Long press detected!');
        // Trigger long press action
    }, LONG_PRESS_DURATION);
});

document.addEventListener('pwa--touch:moved', () => {
    clearTimeout(longPressTimer);
});

document.addEventListener('pwa--touch:ended', () => {
    clearTimeout(longPressTimer);
});
```

### Double Tap Detection

```javascript
let lastTapTime = 0;
const DOUBLE_TAP_DELAY = 300; // ms

document.addEventListener('pwa--touch:ended', (event) => {
    const now = Date.now();

    if (now - lastTapTime < DOUBLE_TAP_DELAY) {
        console.log('Double tap detected!');
        lastTapTime = 0; // Reset
    } else {
        lastTapTime = now;
    }
});
```

### Velocity Calculation

```javascript
let lastTime = 0;
let lastX = 0;
let lastY = 0;

document.addEventListener('pwa--touch:moved', (event) => {
    const touch = event.detail;
    const now = Date.now();

    if (lastTime > 0) {
        const dt = (now - lastTime) / 1000; // seconds
        const dx = touch.clientX - lastX;
        const dy = touch.clientY - lastY;

        const velocityX = dx / dt; // px/s
        const velocityY = dy / dt; // px/s

        console.log(`Velocity: ${Math.round(velocityX)}, ${Math.round(velocityY)} px/s`);
    }

    lastTime = now;
    lastX = touch.clientX;
    lastY = touch.clientY;
});
```

## Complete Example: Advanced Drawing App

{% code lineNumbers="true" %}

```twig
<div {{ stimulus_controller('@pwa/touch') }}>
    <div class="drawing-app">
        <header>
            <h1>Touch Drawing Studio</h1>
        </header>

        <div class="toolbar">
            <div class="tool-group">
                <label>Brush Size:</label>
                <input type="range" id="brush-size" min="1" max="50" value="3">
                <span id="brush-size-value">3px</span>
            </div>

            <div class="tool-group">
                <label>Color:</label>
                <input type="color" id="brush-color" value="#3b82f6">
            </div>

            <div class="tool-group">
                <label>Opacity:</label>
                <input type="range" id="brush-opacity" min="0" max="100" value="100">
                <span id="opacity-value">100%</span>
            </div>

            <div class="tool-group">
                <button id="eraser-btn">Eraser</button>
                <button id="pen-btn" class="active">Pen</button>
            </div>

            <div class="tool-group">
                <button id="undo-btn">Undo</button>
                <button id="clear-all-btn">Clear All</button>
                <button id="save-drawing-btn">Save</button>
            </div>
        </div>

        <canvas id="drawing-canvas-advanced" width="800" height="600"></canvas>

        <div class="touch-indicators" id="touch-indicators"></div>
    </div>
</div>

<script>
    const canvas = document.getElementById('drawing-canvas-advanced');
    const ctx = canvas.getContext('2d');
    const indicators = document.getElementById('touch-indicators');

    // Tools
    const brushSizeInput = document.getElementById('brush-size');
    const brushSizeValue = document.getElementById('brush-size-value');
    const brushColorInput = document.getElementById('brush-color');
    const brushOpacityInput = document.getElementById('brush-opacity');
    const opacityValue = document.getElementById('opacity-value');
    const eraserBtn = document.getElementById('eraser-btn');
    const penBtn = document.getElementById('pen-btn');
    const undoBtn = document.getElementById('undo-btn');
    const clearBtn = document.getElementById('clear-all-btn');
    const saveBtn = document.getElementById('save-drawing-btn');

    // State
    let activeTouches = new Map();
    let brushSize = 3;
    let brushColor = '#3b82f6';
    let brushOpacity = 1;
    let isEraser = false;
    let history = [];
    const MAX_HISTORY = 20;

    // Initialize canvas
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    saveState();

    // Tool handlers
    brushSizeInput.addEventListener('input', (e) => {
        brushSize = parseInt(e.target.value);
        brushSizeValue.textContent = `${brushSize}px`;
    });

    brushColorInput.addEventListener('input', (e) => {
        brushColor = e.target.value;
        if (isEraser) {
            penBtn.click();
        }
    });

    brushOpacityInput.addEventListener('input', (e) => {
        brushOpacity = parseInt(e.target.value) / 100;
        opacityValue.textContent = `${e.target.value}%`;
    });

    eraserBtn.addEventListener('click', () => {
        isEraser = true;
        eraserBtn.classList.add('active');
        penBtn.classList.remove('active');
    });

    penBtn.addEventListener('click', () => {
        isEraser = false;
        penBtn.classList.add('active');
        eraserBtn.classList.remove('active');
    });

    undoBtn.addEventListener('click', () => {
        if (history.length > 1) {
            history.pop();
            const previousState = history[history.length - 1];
            const img = new Image();
            img.onload = () => {
                ctx.clearRect(0, 0, canvas.width, canvas.height);
                ctx.drawImage(img, 0, 0);
            };
            img.src = previousState;
        }
    });

    clearBtn.addEventListener('click', () => {
        if (confirm('Clear the entire canvas?')) {
            ctx.fillStyle = 'white';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            saveState();
        }
    });

    saveBtn.addEventListener('click', () => {
        const dataURL = canvas.toDataURL('image/png');
        const link = document.createElement('a');
        link.download = `drawing-${Date.now()}.png`;
        link.href = dataURL;
        link.click();
    });

    function saveState() {
        history.push(canvas.toDataURL());
        if (history.length > MAX_HISTORY) {
            history.shift();
        }
    }

    // Touch drawing
    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        activeTouches.set(touch.identifier, {
            lastX: touch.clientX - rect.left,
            lastY: touch.clientY - rect.top
        });

        updateTouchIndicators();
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        const touchData = activeTouches.get(touch.identifier);
        if (!touchData) return;

        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        // Draw
        ctx.globalAlpha = isEraser ? 1 : brushOpacity;
        ctx.strokeStyle = isEraser ? 'white' : brushColor;
        ctx.lineWidth = isEraser ? brushSize * 3 : brushSize;

        ctx.beginPath();
        ctx.moveTo(touchData.lastX, touchData.lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        ctx.globalAlpha = 1;

        touchData.lastX = x;
        touchData.lastY = y;
    });

    document.addEventListener('pwa--touch:ended', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);

        if (activeTouches.size === 0) {
            saveState();
        }

        updateTouchIndicators();
    });

    document.addEventListener('pwa--touch:cancelled', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);
        updateTouchIndicators();
    });

    function updateTouchIndicators() {
        indicators.textContent = `Active touches: ${activeTouches.size}`;
    }
</script>

<style>
    .drawing-app {
        max-width: 900px;
        margin: 0 auto;
        padding: 20px;
    }

    header {
        text-align: center;
        margin-bottom: 20px;
    }

    .toolbar {
        display: flex;
        flex-wrap: wrap;
        gap: 20px;
        padding: 15px;
        background: #f9fafb;
        border-radius: 8px;
        margin-bottom: 20px;
    }

    .tool-group {
        display: flex;
        align-items: center;
        gap: 10px;
    }

    .tool-group label {
        font-weight: 500;
        font-size: 14px;
    }

    .tool-group input[type="range"] {
        width: 100px;
    }

    .tool-group input[type="color"] {
        width: 50px;
        height: 35px;
        border: 1px solid #e5e7eb;
        border-radius: 4px;
        cursor: pointer;
    }

    .tool-group button {
        padding: 8px 16px;
        border: 2px solid #e5e7eb;
        background: white;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 500;
        transition: all 0.2s;
    }

    .tool-group button:hover {
        background: #f3f4f6;
    }

    .tool-group button.active {
        background: #3b82f6;
        color: white;
        border-color: #3b82f6;
    }

    #drawing-canvas-advanced {
        display: block;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
        cursor: crosshair;
        background: white;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }

    .touch-indicators {
        margin-top: 15px;
        padding: 10px;
        background: #f3f4f6;
        border-radius: 6px;
        text-align: center;
        font-weight: 500;
    }

    @media (max-width: 768px) {
        .toolbar {
            flex-direction: column;
        }

        #drawing-canvas-advanced {
            width: 100%;
            height: auto;
        }
    }
</style>
```

{% endcode %}

## Troubleshooting

### Touch events not firing

**Possible causes**:

1. Element doesn't have touch-action CSS property set
2. Event listeners on wrong element
3. Browser doesn't support touch events

**Solutions**:

```css
.touch-element {
    touch-action: none; /* Prevent default touch behaviors */
}
```

### Accidental scrolling during drawing

**Issue**: Page scrolls when trying to draw

**Solution**: Use `touch-action: none` on interactive elements:

```css
canvas {
    touch-action: none;
}
```

### Multi-touch not working

**Issue**: Only one touch point detected

**Cause**: Using `started/moved/ended` events instead of `updated`

**Solution**: Use the `updated` event for multi-touch gestures:

```javascript
document.addEventListener('pwa--touch:updated', (event) => {
    const { touches } = event.detail;
    if (touches.length === 2) {
        // Handle two-finger gesture
    }
});
```

### Touch coordinates incorrect

**Issue**: Touch position doesn't match visual location

**Cause**: Not accounting for element offset

**Solution**: Calculate relative coordinates:

```javascript
const rect = element.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
```

### Performance issues with many touch events

**Issue**: App lags during touch interaction

**Solutions**:

1. Throttle touch event processing
2. Use `requestAnimationFrame` for rendering
3. Minimize work in event handlers
4. Use Web Workers for heavy computations

```javascript
let rafId = null;

document.addEventListener('pwa--touch:moved', (event) => {
    if (rafId) return;

    rafId = requestAnimationFrame(() => {
        // Process touch event
        rafId = null;
    });
});
```

### Ghost clicks on touch devices

**Issue**: Touch triggers both touch and click events

**Solution**: Prevent default on touch events when needed:

```javascript
document.addEventListener('touchstart', (e) => {
    e.preventDefault(); // Prevents ghost click
}, { passive: false });
```

{% hint style="warning" %}
Only prevent default when necessary, as it disables scrolling and other default behaviors.
{% endhint %}
