# Touch Input

> How @toolbox-web/grid works on touch devices — gesture map, long-press interactions, hit-target sizing, and known browser quirks.

The grid is **touch-friendly by default**. Native two-finger scrolling, tap-to-focus, tap-header-to-sort, and drag-resize all work on touch screens without any configuration. This page explains how the grid maps gestures to features, which interactions require plugins, and how to tune hit-target sizing for accessibility.

## Overview

`@toolbox-web/grid` handles touch via the Web Pointer Events API (`pointerdown` / `pointermove` / `pointerup`) combined with `setPointerCapture`. This approach is robust under DOM virtualization — because virtualization recycles cell elements mid-gesture, touch events lose their target; pointer capture routes all events to the grid host element regardless of DOM changes. Capture is taken the moment a press turns into a drag rather than on the press itself, so a plain tap or click still reaches the cell underneath it.

The grid renders into **light DOM** with a faux scrollbar pattern: a zero-opacity `div.faux-vscroll` acts as the scroll container, while the visible row area clips and translates. Touch scrolling drives this faux scrollbar via JS, which is why all scrollable elements carry `touch-action: none` (see [Known browser quirks](#known-browser-quirks)).

## Gesture map

The table below shows how each action maps across input types.

| Action | Mouse | Trackpad | Touch |
|---|---|---|---|
| Focus a cell | Click cell | Click / tap | Tap cell |
| Sort by column | Click header | Click header | Tap header |
| Multi-sort (secondary key) | Shift+click header | Shift+click | See note below ↓ |
| Scroll rows (vertical) | Scroll wheel / drag scrollbar | Two-finger swipe | One-finger swipe |
| Scroll columns (horizontal) | Scroll wheel / drag scrollbar | Two-finger swipe | One-finger swipe |
| Resize column | Drag resize handle | Drag resize handle | Drag resize handle |
| Reorder column | Drag header | Drag header | Drag header |
| Toggle row selection (single) | Click row | Click row | Tap row |
| Toggle row multi-select | Ctrl+click row | Ctrl+click | See note below ↓ |
| Range-select rows | Shift+click row | Shift+click | See note below ↓ |
| Range-select cells | Ctrl+click / drag | Ctrl+click / drag | See note below ↓ |
| Open context menu | Right-click | Two-finger tap | Long-press |
| Header menu | — | — | Long-press header |
| Resize a tool panel | Drag splitter | Drag splitter | Drag splitter |
| Paint a cell range | Drag across cells | Drag across cells | Long-press cell, then drag |

All drag interactions in the table above are driven by [Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) with
[pointer capture](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture), so mouse, pen and touch follow one code path — there is no
separate touch implementation to fall out of sync. Cell-range painting is the one gesture that behaves differently by modality: with a fine pointer (mouse,
trackpad) the drag starts as soon as the pointer moves a few pixels, while with a coarse pointer (touch, pen) it requires a 400 ms long-press first, so that a
plain swipe still scrolls the grid.

- **Multi-sort via touch** — secondary header sort will be accessible via the column header menu (long-press header → header menu → Sort → Add sort key).
- **Multi-select via touch** — long-pressing a row enters *selection mode* (a toolbar appears). Subsequent taps toggle rows in that mode. Described further in [Selection-mode UX](#selection-mode-ux).
- **Range-select rows via touch** — long-pressing a second row while in selection mode extends the range from the anchor.
- **Range-select cells via touch** — long-press a cell, then drag to paint the range. Its corners can then be dragged with the range handles.
- **Long-press cell / row (no SelectionPlugin)** — opens the context menu, exactly as right-click does.

## Long-press priority order

When a long-press occurs, the grid resolves the action according to the following priority chain:

1. **Header long-press → Column header menu** (highest priority) — opens the column header menu regardless of which plugins are active.
2. **Row long-press + `SelectionPlugin` in `mode: 'row'` → Selection mode** — enters touch selection mode; a toolbar appears at the top of the grid.
3. **Cell long-press + `SelectionPlugin` in `mode: 'cell'` / `'range'` → Range painting** — drag to paint the range.
4. **Row / cell long-press, nothing above applies → Context menu** — falls back to `ContextMenuPlugin` if it is registered.

This order is the agreed policy for the touch-input epic and applies to all future long-press handlers.

### How the fall-through works

There is no long-press polyfill for the context menu: browsers already synthesise a native `contextmenu` from a touch long-press, just as they do from a
right-click. The grid instead resolves the conflict from the *other* direction — after 400 ms it offers the press to the plugins, and **only if one claims
it** does it suppress the browser's `contextmenu` for a short window. When nothing claims the press, no suppression happens and the menu opens on its own.

Two consequences worth knowing:

- Long-press works with `ContextMenuPlugin` alone — no `SelectionPlugin` required, and no configuration.
- When selection mode *has* claimed the press, the context-menu items are still one tap away via the **More…** button in the selection toolbar, so touch
  users never lose right-click parity.

:::note[No column header menu yet]
There is no column header menu, so step 1 never fires: a header long-press falls through to the context menu with `isHeader: true`. Multi-sort and
"Select column" are therefore not yet reachable by touch.
:::

## Selection-mode UX

When a user long-presses a row on a touch device and `SelectionPlugin` is active in `mode: 'row'`, the grid enters **selection mode**:

- A toolbar appears at the top of the grid showing:
  - **N selected** — count badge
  - **Select all** — selects all rows in the current data set
  - **Clear** — deselects all
  - **More…** — surfaces the `ContextMenuPlugin` items, so touch users keep parity with right-click. Hidden when no `ContextMenuPlugin` is registered.
  - **Done** — exits selection mode
- Tapping a row toggles its selection — no modifier key needed.
- Long-pressing a second row extends the selection from the long-press anchor.
- Tapping **Done**, or pressing `Escape` on an external keyboard, exits selection mode.

Selection mode is **additive**. Mouse users keep `Ctrl+click` and `Shift+click` exactly as before, and the mode is entered from the *event's* `pointerType`
rather than the `(pointer: coarse)` media query — so on a hybrid device such as a Surface, a finger gets selection mode while the mouse keeps its chords.

### `touchMode`

`SelectionConfig.touchMode` controls what happens to the selection when the mode is exited:

| Value | Behaviour |
|---|---|
| `'transient'` (default) | Exiting clears the selection. The mode *is* the selection, as in Gmail. |
| `'sticky'` | The selection survives, so a later round can build on it. |

```ts
new SelectionPlugin({ mode: 'row', touchMode: 'sticky' });
```

Read the current state with the `touchSelectionActive` getter, and exit programmatically with `exitTouchSelection()`.

### Cell range corner handles

When a cell range is started by a **finger or stylus** in `mode: 'range'`, two draggable dots are rendered at the range's top-left and bottom-right corners (the iOS Numbers / Google
Sheets idiom). Dragging one resizes the range.

They are deliberately **touch-only**: touch has no Shift/Ctrl, so the handles are the only way to adjust a range. A mouse-started range never shows them — desktop users adjust with Shift+click and can hold multiple ranges at once, where handles sitting inside another range would only be in the way.

Because the grid virtualizes rows, a handle whose anchor cell has scrolled out of the rendered window is hidden until that cell returns.

## Hit-target sizing

The grid exposes the CSS custom property `--tbw-touch-target-min` to set the minimum interactive hit-target size across all pointer-interactive elements (resize handles, column headers, checkboxes).

| Token | Default | Standard |
|---|---|---|
| `--tbw-touch-target-min` | `24px` | WCAG 2.2 SC 2.5.8 Target Size (Minimum), Level AA |

You will be able to override the token in your theme to increase target sizes for your deployment:

```css
tbw-grid {
  --tbw-touch-target-min: 44px; /* Apple HIG / WCAG SC 2.5.5 Target Size (Enhanced), AAA */
}
```

The default of 24 px satisfies WCAG 2.2 SC 2.5.8 Target Size (Minimum) at Level AA. Raising it to 44 px also satisfies SC 2.5.5 Target Size (Enhanced) at Level AAA, and matches Apple's Human Interface Guidelines recommendation of 44 × 44 pt for primary touch targets.

## Known browser quirks

### `touch-action: none` policy

The grid sets `touch-action: none` on all JS-managed scroll and interaction elements (`.tbw-grid-content`, `.rows-viewport`, `.faux-vscroll`, `.resize-handle`). This is intentional and correct: the grid's faux-scrollbar pattern drives scrolling via JS pointer-capture events, and the browser compositor must not compete with or cancel those handlers mid-gesture.

**Why not `pan-x pan-y`?** That value delegates scrolling to the browser compositor, which bypasses the faux scrollbar entirely — the grid position would update but the custom scrollbar would not.

**Why not `manipulation`?** That value suppresses double-tap-to-zoom, which is a [WCAG 1.4.4 violation](https://www.w3.org/WAI/WCAG21/Understanding/resize-text.html) and breaks pinch-zoom on pages where the grid is embedded.

### Pointer capture and DOM virtualization

Without `setPointerCapture`, a touch-scroll gesture over a grid would stop after ~2 rows because DOM virtualization recycles the original touch-target element. The grid uses `setPointerCapture` to route all pointer events to `.tbw-grid-content` regardless of DOM changes. If your environment disables `setPointerCapture` (some test harnesses or sandboxed iframes), touch scrolling may stop mid-gesture.

### iOS Safari scroll momentum

iOS Safari applies its own scroll momentum to elements with `overflow: auto`. Because the grid uses a faux scrollbar (not native overflow), momentum scrolling is implemented in JS (`touch-scroll.ts`). The momentum curve matches the CSS `ease-out` curve and stops naturally at content boundaries.

## See also

  - [Accessibility](https://toolboxjs.com/grid/guides/accessibility.md): WAI-ARIA roles, keyboard navigation, screen reader support, and WCAG 2.1 AA conformance.
  - [Theming](https://toolboxjs.com/grid/guides/theming.md): CSS custom properties and how to override the grid
