Grid Column Layout

Column Layout

Resize, reorder, pin, and auto width; per-column min/max; controlled state.

Pretable supports column resize, reorder, pin, and auto width out of the box. Width, order, and pinning live behind narrow controlled UI-state slices. Query derivations are controlled separately through the exact query/onQueryChange pair.

Width, order, and pin are all controlled in the grid below — drag a header to reorder it, drag its right-edge handle to resize, double-click the handle to hand the width back to the grid. (Resizing needs a fine pointer; see Resizing is a pointer affordance.) Symbol is pinned left and Note is pinned right, so dragging a column into either group (or out of one) is visible immediately, and the readout beneath the grid echoes the committed order, pins, and widths after every drag:

Resize, reorder, and pin

Column width, order, and pin are controlled here, so the layout gestures below the grid stay visible after each drag.

.md

Resize

Every header where column.resizable !== false exposes a 4px hit-target on its right edge — the one on the grid above. Pointer-down on the handle starts a drag; the engine commits the new width on pointer-up. onColumnWidthsChange fires once per drag-end with the full widths map for all data columns — there is no per-frame chatter during the drag.

Per-column minWidthPx and maxWidthPx clamp the result. Engine-wide defaults are 40px min and 800px max; supply tighter bounds on the column when the data has known constraints.

Double-clicking the resize handle calls grid.setColumnAutoWidth(columnId, true) — the keyboard-free shortcut for handing this column's width back to the grid. See Auto width for what the grid does with it.

The synthetic row-select column has no resize handle. Set resizable: false on any other column to opt it out.

Resizing is a pointer affordance

There is no key that resizes a column, and on a touch device there is no gesture either — under @media (pointer: coarse) the @pretable/ui skin gives [data-pretable-resize-handle] display: none, so the strip generates no boxes at all: nothing painted, nothing hit-testable.

That is a deliberate removal, not an oversight. The handle is 4px wide, and 4px is not a target a finger can acquire — well under the 24px WCAG 2.5.8 asks for. Inflating it instead was the other option and it costs more than it buys: the trailing edge it would need is the same edge the filter funnel and column menu spend on their own 24×24 targets, and dragging a column edge inside a phone-width viewport is a poor interaction even with a large target. So the strip is dropped and the 48px it freed goes to the two controls that remain — see Filtering § The funnel on touch.

display: none rather than a matchMedia guard in @pretable/react is also deliberate: a media query is evaluated by the engine on both sides of a stream, so a server-rendered grid has no client/server disagreement for hydration to reconcile. Nothing changes on a fine pointer — the strip, the drag, and the double-click are exactly as described above.

If your app needs touch resizing, put it behind an explicit control (a menu item calling grid.setColumnWidth, or grid.setColumnAutoWidth) rather than restoring the strip. The tool panel's column row menu already ships exactly that.

Reorder

Pointer-down on the header itself (anywhere except the resize handle) starts a reorder gesture — this is what dragging a header in the example above does. A 5px movement threshold disambiguates from sort clicks — short drags still trigger sort, longer drags trigger reorder.

While dragging, a ghost element follows the cursor and a 2px drop indicator snaps to the nearest column boundary. onColumnOrderChange fires once on drag-end with the new id order.

Region-derived pin. A moved column adopts the pin state of the region it lands in: the leading pinned region gives it pinned: "left", the trailing pinned region gives it pinned: "right", and anywhere between the two leaves it unpinned. Dragging a column into either pinned group pins it there, and dragging a pinned column out of its group unpins it — a right pin can be lost to a drag exactly the way a left pin already could. When pin state changes alongside the reorder, onColumnPinnedChange fires alongside onColumnOrderChange in the same commit.

The drop indicator is the contract: the column lands on the boundary it marks, and that landing position is what decides the pin. A boundary inside a pinned group — between two of its columns, or past the group's outer edge — pins. The boundary at a group's inner edge belongs to the scrollable region and does not.

In practice that makes each pinned column a two-halves target, symmetrically on both sides. Dragging onto the leading half of the first right-pinned column drops the column just ahead of the group, still scrollable; carrying it onto the trailing half drops it inside, right-pinned. The left group mirrors this.

Per-column reorderable: false opts out. The synthetic row-select column is never reorderable.

Pin

Columns pin to either edge — Symbol and Note are declared this way in the example above. Declare it on the column, or set it at runtime:

tsx
const columns: PretableColumn<Row>[] = [
  { id: "symbol", header: "Symbol", widthPx: 120, pinned: "left" },
  { id: "name", header: "Name", widthPx: 240 },
  { id: "actions", header: "", widthPx: 80, pinned: "right" },
];
 
grid.setColumnPinned("symbol", "left");
grid.setColumnPinned("actions", "right");
grid.setColumnPinned("actions", null); // unpin

Left-pinned columns render first, in a sticky group flush against the viewport's left edge; right-pinned columns render last, stacked from the right edge inward in column order. Everything else scrolls between the two groups. Cells in a pinned group carry data-pretable-pinned="left" or data-pretable-pinned="right", and the sticky treatment covers the whole column — body cell, header, sort control, resize handle, and filter funnel.

Array order is visual order. The engine's column array is always grouped — leading pinned columns, then the unpinned ones, then trailing pinned columns — so a column's index in that array is the position it renders at. aria-colindex is derived from the same index, which means assistive technology reports the position the column is actually drawn at rather than a stale array slot. Both setColumnPinned and reorder maintain the grouping: pinning moves the column into its region, and a move that lands in a region takes that region's pin.

You do not have to declare columns in that order. A columns array that interleaves pinned and unpinned entries is regrouped on the way in — at mount, on every prop update, and on the tool panel's Reset columns — with relative order preserved inside each region. So [symbol, note (right), name] becomes [symbol, name, note], and the column you declared second still renders last where its pin puts it.

Pinned columns are never virtualized away. Horizontal virtualization only windows the scrollable group, and that window runs from scrollLeft to scrollLeft + viewportWidth - pinnedRightWidth: it ends before the right-pinned group, which overlays the viewport's trailing edge, but it deliberately does not subtract the left-pinned width. That over-includes the columns currently hidden behind the left-pinned group, which is the conservative direction — a column is rendered when in doubt, never dropped. A pinned column is in the render snapshot at every scroll position, so a pinned "actions" or identity column can't scroll out from under the user.

Setting a pin repositions the column to that pin-region boundary; unpinning leaves it at the current boundary position. Pin both edges wider than the viewport and the scrollable window clamps to zero rather than producing negative geometry — but a grid that is all pinned has nothing left to scroll, so keep the pinned groups a modest fraction of the viewport.

The synthetic row-select column is always at position 0; it is never pinnable, reorderable, or resizable through the gesture or programmatic APIs.

Row grouping changes the drawn column list: grouped columns are dropped from the data area unless hideGroupedColumns: false, and a derived group column is prepended. Because the list is then regrouped into its pinned regions, an unpinned group column heads the scrolling run and therefore sits after your left-pinned columns — pass groupColumn={{ pinned: "left" }} to seat it ahead of them. Read the drawn order from grid.getColumns(), never from the columns prop.

Auto width

Auto width is a mode bit, not a content fit. On, it means "let the grid manage this column's width": the column draws at the renderer's default, or takes a flex share when the column declares flex — nothing measures cell content anywhere in the width path. Off means manual, at whatever width the engine currently stores.

  • grid.setColumnAutoWidth(columnId, auto) — move one column into or out of the auto set.
  • grid.setAllColumnsAutoWidth(auto) — move every column at once, both directions.
  • Double-click on a column's resize handle is the keyboard-free shortcut for the single-column form, turning auto on.
  • The tool panel's column row menu exposes the same bit as an Auto width toggle, and reflects it.

Columns that declare no widthPx start in the auto set; declared ones start manual. Sizing a column yourself takes it out — a resize drag and grid.setColumnWidth are both manual gestures, and each writes the width it lands on.

Turning auto off freezes the column at the engine's stored width. For a column you never resized, that stored width is the same number the grid was already drawing — the renderer's undeclared-width default and the engine's stored default are one shared constant — so the freeze does not move a pixel.

Reset

The tool panel's columns section carries a Reset columns button, and it is the only reset the library ships: it restores the order, pinning, visibility, and auto-width state the grid mounted with. There is no reset method on the grid handle — to recover the mount-time layout from your own toolbar button, control the slices you care about (below) and write your saved snapshot back through state.columnWidths / state.columnOrder / state.columnPinned.

Controlled state

Three independently-controlled slices on state:

  • columnWidths: Record<string, number>
  • columnOrder: readonly string[]
  • columnPinned: Record<string, "left" | "right" | null>

Three matching callbacks:

  • onColumnWidthsChange
  • onColumnOrderChange
  • onColumnPinnedChange

Drag-end-only emission. Programmatic mutations from controlled-prop reapply do not fire callbacks; only user-initiated commits do. This matches the established pattern from sub-project B (selection, focus, sort) — controlled props are a one-way push from consumer to engine, callbacks are a one-way push from user gesture to consumer.

columnOrder is a relative order, not a literal layout. The engine regroups it by each column's current pin state before applying it, so an order that interleaves pinned and unpinned ids is normalised into the leading/unpinned/trailing grouping rather than honoured position-for-position. The order slice never changes pin state; columnPinned and the column config own that.

grid.setColumnOrder(ids) is the imperative form of the same rule, reconciling a whole order in one commit: ids that match no column are ignored, any current columns the caller omitted keep their relative order at the end, the synthetic row-select column stays at position 0, and no column's pin changes.

Each slice is independent: pass the slices you want to own, omit the rest. The engine still owns viewport, virtualization, and any uncontrolled slices. The example above pairs each slice's useState with its matching onColumn*Change callback — that pattern is all columnWidths, columnOrder, and columnPinned need, independently of one another.

See also

  • Selection — cell-range model, controlled state for the selection slice.
  • Keyboard — full keyboard contract.
  • Clipboard — Cmd/Ctrl+C TSV defaults and overrides.
  • Row grouping — the derived group column and where it is seated.
  • API reference — complete type signatures.