Skip to content

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.

@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).

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

ActionMouseTrackpadTouch
Focus a cellClick cellClick / tapTap cell
Sort by columnClick headerClick headerTap header
Multi-sort (secondary key)Shift+click headerShift+clickSee note below ↓
Scroll rows (vertical)Scroll wheel / drag scrollbarTwo-finger swipeOne-finger swipe
Scroll columns (horizontal)Scroll wheel / drag scrollbarTwo-finger swipeOne-finger swipe
Resize columnDrag resize handleDrag resize handleDrag resize handle
Reorder columnDrag headerDrag headerDrag header
Toggle row selection (single)Click rowClick rowTap row
Toggle row multi-selectCtrl+click rowCtrl+clickSee note below ↓
Range-select rowsShift+click rowShift+clickSee note below ↓
Range-select cellsCtrl+click / dragCtrl+click / dragSee note below ↓
Open context menuRight-clickTwo-finger tapLong-press
Header menuLong-press header
Resize a tool panelDrag splitterDrag splitterDrag splitter
Paint a cell rangeDrag across cellsDrag across cellsLong-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.

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.

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.

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.

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

ValueBehaviour
'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().

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.

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.

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.

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 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.