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, which interactions require plugins, and how to tune hit-target sizing for 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.

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

TokenDefaultStandard
--tbw-touch-target-min24pxWCAG 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:

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.

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.

AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://toolboxjs.com/llms-full.txt