Touch Input
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 and which interactions require plugins. For hit-target sizing, keyboard equivalents and the rest of the accessibility surface, see Accessibility.
Overview
Section titled “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).
Gesture map
Section titled “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 with pointer capture, 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.
- 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
Section titled “Long-press priority order”When a long-press occurs, the grid resolves the action according to the following priority chain:
- Header long-press → Column header menu (highest priority) — opens the column header menu regardless of which plugins are active.
- Row long-press +
SelectionPlugininmode: 'row'→ Selection mode — enters touch selection mode; a toolbar appears at the top of the grid. - Cell long-press +
SelectionPlugininmode: 'cell'/'range'→ Range painting — drag to paint the range. - Row / cell long-press, nothing above applies → Context menu — falls back to
ContextMenuPluginif 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
Section titled “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
ContextMenuPluginalone — noSelectionPluginrequired, 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.
Selection-mode UX
Section titled “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
ContextMenuPluginitems, so touch users keep parity with right-click. Hidden when noContextMenuPluginis 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
Escapeon 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
Section titled “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. |
new SelectionPlugin({ mode: 'row', touchMode: 'sticky' });Read the current state with the touchSelectionActive getter, and exit programmatically with exitTouchSelection().
Cell range corner handles
Section titled “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
Section titled “Hit-target sizing”Every interactive element in the grid answers a pointer across at least 24 × 24 CSS pixels, which
is WCAG 2.2 SC 2.5.8 Target Size (Minimum). Because the criterion is not conditional on pointer
type, this is an accessibility guarantee rather than a touch feature, and it is documented with the
rest of them: see Target size for how the grid reaches
the minimum without giving up density, and for the --tbw-touch-target-min and
--tbw-tool-panel-resize-overhang tokens that raise it.
What is specific to touch is that a coarse pointer also enlarges the visible box of several controls. That is a comfort choice on top of the target minimum, not the thing that satisfies it.
Known browser quirks
Section titled “Known browser quirks”touch-action: none policy
Section titled “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 and breaks pinch-zoom on pages where the grid is embedded.
Pointer capture and DOM virtualization
Section titled “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
Section titled “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.