Skip to content

Interactions

View as Markdown

Interaction configuration is fixed for the lifetime of a GridRuntime. The configuration separates keyboard focus, cell selection, active-row context, and row-operation selection.

cell-grid uses a cell cursor. Arrow keys move between cells and Shift+Arrow can extend a rectangular cell range. Editing is available in this mode.

row-list uses a row cursor. Arrow keys move between selectable rows and may extend row selection. It has no active cell or cell range.

type GridInteractionConfig =
| CellGridInteractionConfig
| RowListInteractionConfig;
type CellGridInteractionConfig = {
readonly mode: "cell-grid";
readonly activeCell: {
readonly kind: "enabled";
readonly keyboard: ActiveCellKeyboardConfig;
};
readonly selectedCells:
| { readonly kind: "none" }
| { readonly kind: "range" };
readonly activeRow:
| { readonly kind: "none" }
| { readonly kind: "from-active-cell" };
readonly selectedRows: SelectedRowsConfig;
};
type RowListInteractionConfig = {
readonly mode: "row-list";
readonly activeCell: { readonly kind: "none" };
readonly selectedCells: { readonly kind: "none" };
readonly activeRow: {
readonly kind: "from-row-cursor";
readonly keyboard: ActiveRowKeyboardConfig;
};
readonly selectedRows: SelectedRowsConfig;
};

Pass the configuration during construction:

const runtime = createGridRuntime({
schema,
dataSource,
interaction: CELL_GRID_WITH_INDEPENDENT_ROW_SELECTION,
});

runtime.interaction contains the normalized immutable configuration.

type SelectedRowsConfig =
| { readonly kind: "none" }
| {
readonly kind: "enabled";
readonly mode: "single" | "range" | "multi";
readonly sync:
| { readonly kind: "follows-active-row" }
| { readonly kind: "independent" };
readonly keyboard: {
readonly space: "toggle-active-row" | "ignore";
};
};

follows-active-row derives the effective selection from the active row. It is a read-only projection. independent stores a path-local row selection that can differ from the active row.

type RowSelection =
| { readonly kind: "single"; readonly rowId: RowId }
| {
readonly kind: "range";
readonly anchor: RowId;
readonly head: RowId;
}
| { readonly kind: "set"; readonly rowIds: ReadonlySet<RowId> }
| null;

Read and change row state through a GridLevelRuntime:

const level = runtime.root;
const rowId = makeRowId(level.path, "task-1");
level.activeRow();
level.selectedRows();
level.selectedRowIds();
level.rowInteractionSnapshot();
level.selectRow(rowId);
level.toggleRowSelection(rowId);
level.extendRowSelectionTo(rowId);
level.clearRowSelection();

Selection commands normalize row ids against the current displayed rows and the configured selection mode. Non-selectable and stale rows are removed.

Level subscriptions correspond to distinct read models:

level.subscribeActiveRow(() => {
renderInspector(level.activeRow());
});
level.subscribeSelectedRows(() => {
persistSelection(level.selectedRows());
});
level.subscribeSelectedRowIds(() => {
updateCount(level.selectedRowIds().length);
});
level.subscribeRowInteractionSnapshot(() => {
updateRowChrome(level.rowInteractionSnapshot());
});

subscribeSelectedRows() observes the configured selection value. subscribeSelectedRowIds() also observes displayed-order changes that alter the projected ids. subscribeRowInteractionSnapshot() is designed for row decoration.

React components use the corresponding hooks:

useActiveCell();
useActiveCellForPath(path);
useCellSelection(path);
useActiveRow(path);
useSelectedRows(path);
useSelectedRowIds(path);
useRowInteractionSnapshot(path);

rowSelectionColumn() creates a normal ColumnSchema that reads path-local row status and invokes the level selection commands.

import { rowSelectionColumn, text } from "@sapporta/grid/column-preset";
const columns = [
rowSelectionColumn(),
text({ id: "title", name: "Title", edit: "default" }),
];

Set the level’s rowHeaderColumn to { column: selectorColumnId } when row-header behavior should be attached to that column. Use "empty-selectable-cell" for a separate row header or "none" when the level has no row header.

Stored selection remains path-local. Commands that intentionally span the expanded hierarchy use runtime.rowOperations:

const selected = runtime.rowOperations.selectedDataTargets();
const result = await runtime.rowOperations.remove(selected);
if (result.kind === "partial") {
showError(result.failed.rowKey, result.error);
}

runtime.rowOperations.targets() uses explicit row selection when present on a path and falls back to rows covered by cell selection. It returns current operation targets in registered-level order.

Raw cursor and controller APIs are advanced composition surfaces. Import them from @sapporta/grid/advanced:

import { controllerFor, cursorManagerFor } from "@sapporta/grid/advanced";
const level = runtime.root;
const rowId = makeRowId(level.path, "task-1");
const cursors = cursorManagerFor(runtime);
cursors.moveCellCursorTo({ path: level.path, rowId, colId: "title" });
cursors.extendCellSelectionTo({ path: level.path, rowId, colId: "status" });
cursors.clearCellRange(level.path);
const controller = controllerFor(runtime, level.path);
controller.startEdit({ rowId, colId: "title" }, "f2");
controller.cancelEdit();

cursorManagerFor(runtime) returns one identity-stable facade per runtime. controllerFor(runtime, path) returns one facade for the current level registration. Commands throw after the runtime or registration is disposed.

The exported presets are ordinary GridInteractionConfig values:

PresetModeCell selectionActive rowSelected rows
CELL_EDITING_GRIDcell-gridrangenonenone
CELL_EDITING_NO_SELECTION_GRIDcell-gridnonenonenone
CELL_GRID_WITH_ACTIVE_ROWcell-gridrangeactive cellnone
CELL_GRID_WITH_INDEPENDENT_ROW_SELECTIONcell-gridrangeactive cellindependent multi
CELL_PRIMARY_WITH_SIDE_PANEL_ROWcell-gridrangeactive cellfollows active row
CELL_PRIMARY_WITH_SELECTED_SIDE_PANEL_ROWcell-gridrangeactive cellindependent single
ROW_PRIMARY_MASTER_DETAILrow-listnonerow cursorfollows active row
ROW_MULTISELECT_LISTrow-listnonerow cursorindependent multi

Use satisfies GridInteractionConfig for custom configurations:

const interaction = {
mode: "row-list",
activeCell: { kind: "none" },
selectedCells: { kind: "none" },
activeRow: {
kind: "from-row-cursor",
keyboard: {
arrows: "move-active-row",
shiftArrows: "extend-selected-rows",
expansion: "left-right-enter",
},
},
selectedRows: {
kind: "enabled",
mode: "range",
sync: { kind: "independent" },
keyboard: { space: "toggle-active-row" },
},
} satisfies GridInteractionConfig;

Grid rows expose interaction state through data attributes. Use the attributes for chrome rather than reading controller state from each cell:

<div
data-grid-part="row"
data-row-active="true"
data-row-selected="true"
data-row-kind="data"
></div>

See DOM and styling contract for the complete attribute surface.