# 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 %}


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://pwa.spomky-labs.com/symfony-ux/touch.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
