Active Foundation

Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

Keyboard

Cell-level keyboard navigation, the keys, and the keyboard paths for what the pointer drags.

@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3

The package's own guide

This guide is the package's docs/guides/keyboard.md, included unchanged from the package as it is in Foundation. Links to the package's internal design documents keep their text.

Cell-level keyboard navigation is on by default since 8.0.0. Nothing has to be passed to get it.

The grid declares role="grid", and a grid is keyboard-navigable by definition. Shipping the role with the navigation switched off was an incorrect declaration and a WCAG 2.1.1 (level A) failure, so the default was inverted rather than left as an opt-in.

What has been observed of this contract, per browser and date, is recorded in ../ACCESSIBILITY-EVIDENCE.md.

TSX
<ActiveGrid  onCellActivate={(row, columnId) => {    // Called when Enter or Space is pressed on a focused, non-editable cell.    // Editable cells start an edit instead — see "Editing by keyboard".  }}/>

Opting out#

TSX
<ActiveGrid enableKeyboardNavigation={false} />

This restores the pre-8.0.0 behaviour: the grid element gets no tabIndex, cells get none either, and the cell plane cannot be reached by keyboard at all.

Editing by keyboard#

Enter on a focused editable cell starts the edit. This is the only keyboard route into editing: the pointer triggers (editTrigger="click" and "doubleClick") hang off onClick / onDoubleClick, and a <div role="gridcell"> does not synthesise a click from Enter.

It works the same way in all three editing modes:

editModeEnter on an editable cell
cellOpens that cell's editor
rowOpens the whole row for edit
batchOpens that cell's editor

onCellActivate is not called for editable cells: one keypress must not both activate and edit. Non-editable cells still call it.

Row click and cell focus coexist#

onRowClick and onRowDoubleClick fire regardless of keyboard navigation. Before 8.0.0 enabling navigation silently suppressed both, along with the cursor-pointer affordance. That exclusion is gone: a row click and a cell focus are events at different levels.

Keyboard Shortcuts#

Grid Navigation#

KeyAction
Arrow RightMove focus to next cell
Arrow LeftMove focus to previous cell
Arrow DownMove focus to cell below
Arrow UpMove focus to cell above
HomeMove focus to first cell in row
EndMove focus to last cell in row
Ctrl+HomeMove focus to first cell in first row
Ctrl+EndMove focus to last cell in last row
PageDownMove focus down by one viewport height
PageUpMove focus up by one viewport height
EnterActivate focused cell (triggers onCellActivate)
SpaceActivate focused cell (triggers onCellActivate)
Shift+F10Open the row context menu for the active cell
ContextMenuOpen the row context menu for the active cell
Ctrl/Cmd+CCopy: the cell range, else selected rows, else the active cell
Ctrl/Cmd+ASelect all rows (not cells)
Ctrl/Cmd+ZUndo the last recorded cell edit (when undo/redo is on)
Ctrl/Cmd+YRedo the last undone cell edit (when undo/redo is on)
Ctrl/Cmd+Shift+ZRedo, as Ctrl/Cmd+Y

Ctrl/Cmd+Z and Ctrl/Cmd+Y act only while undo/redo is on, which is the default whenever onDataCommit is set (enableUndoRedo), and not in editMode="batch". They are read from a focused body cell, not from an open editor: inside an input the browser's own text undo applies. With nothing to undo or redo the keystroke is left to the browser. What is recorded, and how an undo reaches the data source, is in editing.md.

Initial Focus#

When the grid element itself is focused (via Tab) and no cell has focus:

KeyAction
Arrow DownFocus the first cell (row 0, column 0)
Arrow RightFocus the first cell (row 0, column 0)
EnterFocus the first cell (row 0, column 0)

NumpadEnter reports key: 'Enter' and therefore behaves identically. A modified keystroke (Ctrl, Meta or Alt) is a different action and is not this entry point.

The container is the grid's one entry. Before a cell has been active, no body cell and no row control is a stop of its own: the first Tab into the grid always lands on the container, never on a first cell.

Returning to the grid after leaving it restores the last focused cell.

This entry point never fires for a control inside the grid#

The word "itself" above is the whole contract, and it is enforced with event.target === event.currentTarget (10.1.2).

A keystroke that originates from a control — a header menu button, a filter input, the clear button of an active filter, a button inside a body cell — is that control's keystroke, never the grid's. The same holds for content rendered through a React portal from inside the grid, such as a filter popover or a column menu: those sit outside the grid in the DOM, but they still bubble to the grid through the React tree, and they are not the entry point either.

There is deliberately no allowlist of buttons, links, inputs, textareas, selects, contenteditables or interactive roles. Anything that is not the grid container itself belongs to something else, so an unknown target fails safe without anyone having to extend a list.

The same rule governs Enter and Space inside a cell. They activate the cell — see Grid Navigation above — only while the cell itself holds focus. On a cell that has interactive children, that first keystroke enters action mode and moves focus to the first child. From that point both keys belong to the child: the grid does not cancel them and does not move focus, so the child activates natively. Escape, Tab and the arrow keys stay with the grid — they carry action mode itself.

Row controls are reached through action mode#

The controls the grid renders in every row — the selection checkbox, the expander, and the toggle of a client or server group row — are not stops in the page's Tab sequence. Tab would otherwise visit two controls per row: 20 stops on a page of ten rows, 200 on a page of a hundred. They carry tabIndex={-1} and action mode reaches them:

KeyIn action mode
Enter / Space on cellFocus the cell's first control
Space / EnterOperate the control (toggle the selection, expand)
Tab / Shift+TabNext / previous control in the cell; past the last, back
EscapeLeave action mode; focus returns to the cell

A group row, client or server, is a row of the grid's navigation like any other: the arrow keys reach it, Enter on its first cell reaches the toggle, and on a server group row Arrow Right and Arrow Left expand and collapse it directly.

The "load more" row of server grouping is one too. Its cells hold no control, so Enter or Space on any of them loads the group's next block at once, the same action a click takes; the cells announce the keys with aria-keyshortcuts, and the row is aria-busy while the block is on its way. Keys pressed in that time ask for nothing more. When the block lands, the new rows take the row's display position and the active cell keeps it, so the focus is on the first new row and the next Arrow Down reads on.

Action mode's stops are the natively focusable elements of the cell plus every element with a tabindex, including -1. Excluded are hidden elements, anything under aria-hidden="true" (the native proxy input of a checkbox primitive), and a control with aria-disabled="true".

A control the consumer renders in a cell keeps whatever tab stop it declares. To join this model and stay out of the Tab sequence, give it tabIndex={-1}; action mode reaches it all the same.

With enableKeyboardNavigation={false} there is no action mode, and the row controls keep their natural stop.

Edit Mode#

When an input or textarea inside a cell has focus (during editing):

KeyAction
EnterCommit the edit and move to next editable cell in the same column
EscapeExit edit mode, return focus to the cell
(other keys)Handled by the editor, not by grid navigation

Arrow keys and other navigation keys are passed through to the editor when editing. This prevents accidental navigation while typing.

Input method composition#

Every grid key handler that acts on Enter, Escape, Tab or an arrow key (the cell editor, cell navigation and range selection, the container entry point, the sortable header, the date filter inputs) returns early on a composing key (isComposing, or keyCode 229). An open IME candidate therefore never commits, cancels or moves anything in the grid; see Editing § Input method editors.

Focus Management#

Focus State#

The focused cell is tracked as a CellPosition:

typescript
interface CellPosition {  rowIndex: number;  colIndex: number;}

Visual Indicator#

The active cell receives:

  • tabIndex={0} (other cells have tabIndex={-1}), and the focus: the grid keeps the browser's focus and its own active cell on the same cell. A press makes the cell active on the press itself (onMouseDown), so the two agree from the first frame, also when the pointer is dragged on.

  • data-focused="true" data attribute.

  • One square mark: one pixel at 50% of --ring on all four sides, drawn from data-focused and the grid's data-body-focus, not from the browser's :focus. A click and an arrow key make a cell active alike, and one mark serves both. When the focus leaves the grid the mark goes; a menu the grid opened keeps it. An editor or a control inside the cell that takes the focus shows its own focus, and the cell mark gives way. Inside a cell range the active cell has no mark of its own; see Cell selection.

A sortable header label is the opposite case: it shows the browser's keyboard focus ring for keyboard use only. A label the pointer focused carries data-pointer-focus and shows no ring, also while a held Shift (multi-sort) auto-repeats; the first key that is not a modifier, or leaving the label, removes the attribute.

The grid element itself marks keyboard focus with its own border at 50% of --ring (focus-visible:border-ring/50), for the moment focus rests on the grid and not on a cell: on entry, and while focus is parked there (see Focus continuity). Only one element holds focus, so the container mark and the cell mark never draw together. The styling contract is in styling.md.

Cell Registration#

Each cell registers its DOM element via registerCellRef. This allows the keyboard handler to programmatically focus the correct element when navigation occurs.

typescript
// Internal: each BodyCell calls this on mount/unmountregisterCellRef(rowIndex, colIndex, element);

Scroll-to-Focus (Virtualized Grids)#

When keyboard navigation moves focus to a row that is outside the visible viewport (in a virtualized grid), the grid automatically scrolls to bring the target row into view:

  1. The new CellPosition is set in state.

  2. useKeyboardNavigation checks if the cell's DOM element exists via cellRefs.

  3. If the element exists: element.focus() + scrollIntoView({block: 'nearest'}).

  4. If the element does not exist (virtualized off-screen):

    • Call virtualizer.scrollToIndex(centerRowIndex, {align: 'auto'}) to scroll the target row into view.

    • Look for the newly rendered cell on each animation frame, for up to fifteen frames, and focus it once it is registered.

The hook receives scrollToIndex from the virtualizer via a ref threaded through the grid's view layer. The absoluteIndex from keyboard navigation is converted to a center-row index by subtracting the top pinned-row count.

The frame loop is needed because the virtualizer settles its scroll over several frames when row heights are measured, and the cell ref is only registered once its row has mounted.

Why virtualizer.scrollToIndex#

Previous versions estimated scroll position as rowIndex * rowHeight, which assumed fixed row heights. This broke after ~50 rows with dynamic heights (estimateSize, expanded rows, autoHeight cells). virtualizer.scrollToIndex uses the virtualizer's internal measurement cache for accurate positioning regardless of row-height variation.

The scrollElement is only connected to the keyboard handler in virtualized mode (when height is a number and row-count exceeds the threshold).

Focus continuity: when the active cell leaves the DOM#

The grid removes elements on its own: a virtualized row or column leaves the mounted window when the reader scrolls it away, and a control inside a cell can be replaced by a render. The browser then hands focus to <body>, and a keyboard reader would have no position left. The grid keeps that position:

  • Focus parks on the grid. When the element that held focus leaves the DOM, focus moves to the active cell if it is still mounted, and otherwise to the role="grid" element. A cell the grid replaces while it holds the focus (a row whose content changes under the active position) takes the same path. It does not scroll: the reader's scroll position stays theirs. The active cell (its row and column) is unchanged.

  • Focus returns by itself. While focus is parked on the grid and the active cell mounts again, for instance because the reader scrolls it back into view, focus moves back to that cell.

  • The next key resumes. On the parked grid, an arrow key, Home, End, PageUp or PageDown moves from the active cell, scrolling it back into view; Enter, Space and F2 bring the active cell back without activating it. If the active cell no longer exists (a filter or a page change left fewer rows or columns), its coordinate is clamped to the nearest cell of the current model.

  • Returning by Tab lands on the active cell when it is mounted, and otherwise on the grid, where the next key resumes as above.

This holds for row and column virtualization alike, and for any other way the active cell's element disappears. The active-cell mark is only drawn on a cell that really holds focus; there is no mark on a cell focus has left.

The recovery has two triggers: the browser's focusout of a removed element, and a registered cell that is unregistered while it holds the focus. The second does not depend on the engine dispatching that event.

PageUp / PageDown#

PageUp and PageDown move focus by approximately one viewport height:

Text
pageSize = Math.floor(scrollElement.clientHeight / rowHeight)
  • PageDown: newRow = Math.min(currentRow + pageSize, lastRow)

  • PageUp: newRow = Math.max(currentRow - pageSize, 0)

If the scroll element is not available (e.g., auto-height mode), a default viewport of 400 px is used.

Tab Order#

The tab order through the grid follows this sequence:

  1. Skip link (visually hidden until focused).

  2. Toolbar (search input, action buttons).

  3. The grouping panel, when grouping is enabled.

  4. The role="grid" element itself.

  5. Column headers and the filter row.

  6. The active cell, once there is one — and nothing else in the body.

  7. Pagination (page buttons, page-size selector).

The tab stop is the role="grid" element. It is not the inner .grid-scroll-container: a focusable element with no role sitting directly inside role="grid" is an aria-required-children violation, which axe rates critical. Focus-visible styling sits on that same element, in the package stylesheet (styles/chrome.css): its own border at 50% of the ring, no ring.

CSS
.grid-frame--focusable:focus-visible {  border-color: color-mix(in oklab, var(--grid-ring) 50%, transparent);}

The body contributes at most one stop, the active cell, but the header still has a sort label, a column menu and filter controls per column. The skip link in front of the grid gets past them. It lands on a sentinel placed directly after the role="grid" element, so the header, filter row and body are skipped while pagination and the batch toolbar stay reachable.

Before 8.0.0 the link pointed at the role="grid" element and was labelled "skip to grid content". It did nothing: the target had no tabIndex, and a fragment link to a non-focusable element does not move focus. Working, it would still only have duplicated what the next Tab already did.

ARIA Roles#

Active Grid applies semantic ARIA roles for screen-reader accessibility:

ElementARIA Role
Grid containerrole="grid"
Header rowrole="row"
Header cellrole="columnheader"
Body rowrole="row"
Body cellrole="gridcell"
Footer rowrole="row"
Selection checkboxaria-label="Select row" / "Select all"
Header checkboxaria-checked: false / mixed (part of the page) / true
Expander togglearia-label="Expand" / "Collapse", aria-expanded
Group togglearia-label="Expand group" / "Collapse group", aria-expanded
Sort indicatoraria-sort="ascending" / "descending"
Empty-state messageOne row with one gridcell holding the message
Body while loadingrowgroup with aria-busy="true"

What the grid says it can select follows what the reader can select: aria-multiselectable="true" with enableCellSelection, and with row selection that is not limited to one row (enableRowSelection with enableMultiRowSelection left on). A single-row grid without cell selection, and a grid without selection, state nothing; that is the attribute's default. A selected row carries aria-selected="true" in every case.

Every body row carries aria-rowindex, counted over the presented rows after the rows above the body: data rows, client group rows, and the group and "load more" rows of server grouping alike. In a virtualized body the index is the mounted row's place in the whole list, not in the window. The rows above the body are the header levels plus the filter row when it renders (showFilterRow, or a filterable column with meta.filterType); a grid without a filter row reserves no place for one. aria-rowcount is those rows plus the total row count. The header, the body sections and the grid read one decision for whether the filter row exists. The index is an ARIA position; keyboard navigation keeps its own coordinates.

The role="grid" element owns rows and row groups only. The grouping panel (the chips of the active grouping, with their remove buttons) is therefore rendered directly above it, as the top of the same card, and not inside it.

Accessible names carry the column#

Controls that exist once per column are named after that column, so a screen-reader user can tell them apart. The column's label is meta.label when the column declares one, else a string header, else the column id. A column whose header is a render function has no text the grid can read, and should declare meta.label.

ControlName (English defaults)
Filter-row value controlFilter value for {column}
... with a chosen valueFilter value for {column}: {value}
Funnel button, and its popoverFilter {column}
Clear buttonClear filter {column}
Condition select in the popover"Condition"
Value fields in the popover"Value", or "From" / "To" for a range
Column menu buttonColumn options for {column}
Sort label in a multi-column sort{column} sort order {n}
Cell editor{column}
Server group rowshows the grouped column's label, not the field id

The words come from the host's translator through the grid.* keys (grid.filter.valueFor, grid.filter.valueForWithValue, grid.filter.openFilterFor, grid.filter.clearFilterFor, grid.filter.condition, grid.filter.value, grid.filter.rangeFrom, grid.filter.rangeTo, grid.header.optionsFor, grid.sort.priority, grid.filterBuilder.column); the column label and the value are variables, never concatenated English.

Column order and width without a pointer#

Dragging a header and dragging a column edge are pointer gestures. The column menu (the header's options button) carries their keyboard alternative:

Menu itemShown whenOne activation
Move leftenableColumnReorderingOne valid position to the left
Move rightenableColumnReorderingOne valid position to the right
Increase widthenableColumnResizing, and the column allows it10 px wider, up to the column's maximum
Decrease widthenableColumnResizing, and the column allows it10 px narrower, down to the column's minimum
  • The menu stays open after these items, so a column travels or grows by repeating Enter on the same item. Escape closes the menu and returns focus to the column's options button. Every step is applied as it is taken, so leaving is always safe and the order and widths stay valid.

  • A move follows the rules of a drop. It never crosses a pinned boundary, never leaves its header group, skips hidden columns, and never passes a grid-owned column (selection, row number, expander, actions). A direction with no valid position is disabled; a column that can move nowhere shows no move items.

  • A width goes through the same acceptance as a drag, which holds it between the column's bounds (minSize, maxSize, else the defaults), and a changed width locks the column exactly as the end of a drag does.

  • Every step is announced through the grid's polite live region: the column's new position ("Name moved to position 2 of 5", the position a screen reader meets as aria-colindex), its new width, or that it is at its minimum or maximum width. A pointer drag announces nothing.

The words come from the host's translator: grid.move.label, grid.move.left, grid.move.right, grid.layout.increaseWidth, grid.layout.decreaseWidth, grid.announce.moved, grid.announce.resized, grid.announce.atMinWidth and grid.announce.atMaxWidth, with the column label, position, total and width as variables.

A column whose header menu is suppressed (meta.headerMenu.enabled: false or meta.suppressHeaderMenuButton) has no keyboard path to these steps; keep the menu on a column the reader should be able to move or resize.

The row context menu and the active cell#

A right-click on a body cell and Shift+F10 (or the ContextMenu key) on the active cell open the same menu, for the same target:

  • Outside a range, the target cell becomes the active cell. A right-click focuses the cell it lands on; the grid's position follows, so the next arrow key moves from that cell and not from the cell that was active before.

  • Inside a range, nothing moves. The range, the active cell (the range's anchor) and the focus stay where they are; the menu is about the range, and on close the focus returns to the active cell.

  • With enableCellSelection, the menu is opened for one of three contexts, decided when it opens. Inside a range of more than one cell it is a range context and every range stays as it was. Outside every range, all ranges are cleared and it is a cell context. A single selected cell is the active cell, not a range, so it is a cell context too. Row selection is never changed by opening the menu.

  • Without enableCellSelection it is the row menu, as it always was.

  • The row's actions stay. A source row keeps its own menu (the contextMenu builder, edit, pin, export, reset) in every context.

  • A generated row. A client group row offers the grid's copy of what its cells show, and only that, when cell selection is on: no builder, no edit, no pin, and no record is invented for it. A server group row opens no grid menu.

  • Focus. The open menu holds focus, so the arrows move in the menu. Opened by Shift+F10 or the ContextMenu key, the menu focuses its first item; opened by the pointer, it focuses the menu itself. The arrows wrap, Home and End reach the first and last item, typing a letter jumps to the next item that starts with it; ArrowRight opens a submenu (ArrowLeft in rtl) and Escape closes one level. Tab closes the whole menu. A disabled item stays reachable and reads its reason. Under a coarse pointer every item is at least 44 px high. Active Charts behaves the same (ADR-058). When the menu closes, focus returns to the active cell, where the next key continues. An action that opens a dialog follows the contract below.

The keyboard path is the grid's own and does not depend on the browser turning Shift+F10 into a contextmenu event; the browser's own event for that keystroke is suppressed, so no second menu opens. rowMenu={{enabled: false}} removes the grid menu altogether; the active cell still follows a right-click.

Copy actions, named by scope#

Every copy item says what it copies, so no single "Copy" means different things depending on state the reader cannot see:

ContextItems
rangeCopy selection · Copy selection with headers
cellCopy cell · Copy row · Copy row with headers · Copy selected rows (N)
rowCopy selected rows (N) and with headers, or Copy row and with headers

"Copy cell" appears on a data column only, and "Copy selected rows (N)" only while rows are selected. On a generated row the menu holds the range or cell items alone.

The Ctrl+C hint sits on one item: the one that does what Ctrl/Cmd+C does right now (a cell range first, then the selected rows, then the active cell). It is exposed as aria-keyshortcuts, and its visible text is not part of the item's name. Where Ctrl+C would copy something no item offers (the active cell in a row context), no item carries it. "Copy selection" writes exactly the block Ctrl+C writes.

Focus Restoration after Dialogs#

When opening a dialog or modal from a grid context menu, some dialog implementations return focus to document.body instead of the grid when closing. Use the restoreFocusRef prop to manually restore focus to the last active cell:

TSX
const restoreFocusRef = useRef<(() => boolean) | null>(null);
<ActiveGrid  gridId="people"  mode="client"  data={people}  columns={columns}  restoreFocusRef={restoreFocusRef}/>;
// In your dialog close handler:function handleDialogClose() {  restoreFocusRef.current?.(); // true when focus was restored}

This pattern is the recommended escape hatch when automatic focus restoration via handleContainerFocus does not fire (e.g., focus lands on document.body instead of the grid container).

Cell selection (range-select)#

Cell-range selection is off by default. Turn it on with enableCellSelection; it is independent of row selection (the checkbox column).

TSX
<ActiveGrid  gridId="invoices"  data={invoices}  columns={columns}  enableCellSelection  onCellSelectionChange={(ranges) => {    // `ranges` is the explicit selection: CellRange[], every range; `[]` when    // only the active cell is selected.    console.log('Selected ranges:', ranges);  }}/>

Interaction#

Two things, each with one owner. The active cell is the keyboard position, and the anchor of every extension. A range is an explicit selection: a drag that reached a second cell, a Shift extension or a Ctrl/Cmd addition. A plain click stores no range; the one cell it selects is the active cell, which shows the one-cell look (a square mark and a small cue).

InputActive cellRanges
ClickThe clicked cellEnded; nothing is stored
Click on the active cellUnchangedUnchanged: nothing changes at all
DragThe pressed cell (anchor)One range from the anchor to the cell under the pointer
Shift+clickStays (anchor)One range from the anchor to the clicked cell
Shift+Arrow, Shift+Home/EndStays (anchor)The far end moves and is brought into view
Ctrl/Cmd+click or dragThe clicked cellAdded; the earlier selection, the active cell included, stays
Ctrl/Cmd+Shift+clickStays (anchor)The last range is replaced by one from the anchor to the cell
An arrow that moves the cellMovesEnded
EscapeStaysEnded; without a range, nothing happens
Starting an editStaysEnded
Ctrl/Cmd+C–Copies the selection (see below)
Right-click, Shift+F10See the menu sectionSee The row context menu

After Escape the grid looks exactly as after a click on the active cell. When a Shift+arrow takes the far end so far that the anchor leaves the mounted window of a virtualized grid, the focus parks on the grid (see Focus continuity) and the selection keys keep working from there: Shift+arrow extends on, Ctrl/Cmd+C copies, Escape ends the range and brings the active cell back, and Shift+F10 brings it back and opens its menu.

Ctrl+A selects all rows, not cells. There is no Ctrl+Shift+Arrow to the edge of the data, and a drag does not scroll the grid.

Several ranges are one selection. Everything that acts on "the selection" (Ctrl+C, the menu's copy items) acts on every range together; there is no active range.

A range is a set of display positions. It ends when a sort, filter, row pin or column change puts other rows or columns at the positions it covers, so a copy never takes data that moved in after the selection was made.

The callback. onCellSelectionChange receives the explicit ranges, CellRange[], after each finished change: once at the end of a drag, not on every pointer move, and [] when the selection ends. A click reports nothing: it changes the active cell, not the ranges.

For assistive technology every cell in an explicit range carries aria-selected="true", and the grid declares aria-multiselectable="true". Only true is stated, so a row's own selection is never contradicted. The active cell is a separate fact (data-focused, the one tabIndex=0, and the focus); the one-cell look of a clicked cell is focus, not selection, so it carries no aria-selected.

Turning cell selection off ends the selection. When enableCellSelection goes from true to false, every range is cleared and onCellSelectionChange reports [] once. Nothing of the range stays behind out of sight: no cell is painted or aria-selected, Ctrl+C and the menu's copy items fall back to the selected rows and then the active cell, a custom menu builder receives a row context without selection, and turning the feature on again brings no range back. The row selection and the active cell are not touched.

What it looks like: the active cell outside a range has one square mark and, with cell selection on, a small square cue on its bottom-right corner. A range has one light fill, one thin outline on its outer edges and one cue on the last range's bottom-right corner; the active cell inside it is left unfilled (the window) and has no mark of its own. When the focus leaves the grid the active cell's look disappears and a range stays. Nothing moves content. The contract is in styling.md.

Copy from cell-selection#

Ctrl/Cmd+C, and the menu's "Copy selection", copy the enclosing rectangle of every range; cells outside the ranges are left empty, and the grid's own columns (selection, expander, actions) are left out. "Copy selection with headers" adds one header row with the columns' labels (meta.label, else a string header, else the id), also when there are several ranges. Values are the presentation form, through the clipboard TSV boundary; see export.md § Clipboard is a presentation copy.

Copy feedback (opt-in toast)#

Since v2.0, active-grid no longer triggers a toast on copy-success/error automatically. Copy still works — data lands in the clipboard — but no visual feedback is shown by default. Consumers wire their own UX feedback via two meta callbacks:

typescript
import type {GridMeta} from '@workspace/active-grid';
interface GridMeta {  // ... other fields ...  onCopySuccess?: (count: number) => void;  onCopyError?: () => void;}

Both are called once per copy, from the row context menu's copy items and from Ctrl/Cmd+C. onCopySuccess receives the number of rows copied: the copied rows, the rows a cell range spans, or 1 for a single cell.

Pattern 1 — Per-grid, inline#

The simplest pattern. Each grid wires its own toast directly:

TSX
import {ActiveGrid} from '@workspace/active-grid';import {toast} from './notifications'; // host-owned notification adapter
<ActiveGrid  gridId="users"  data={users}  columns={columns}  meta={{    onCopySuccess: (count) => toast.success(t('grid.copy.success', {count})),    onCopyError: () => toast.error(t('grid.copy.error')),  }}/>;

Good for one-off grids or when each grid has a different toast style. For 5+ grids you start repeating yourself — see Pattern 2.

Pattern 2 — Shared helper-hook#

DRY-ify the boilerplate across many grids. Place a hook in your app's shared grid-utils:

TSX
// host/grid/use-grid-copy-meta.ts'use client';import {useMemo} from 'react';import {useTranslations} from '@workspace/active-i18n/react';import {toast} from './notifications'; // host-owned notification adapter
export function useGridCopyMeta() {  const t = useTranslations();  return useMemo(    () => ({      onCopySuccess: (count: number) =>        toast.success(t('grid.copy.success', {count})),      onCopyError: () => toast.error(t('grid.copy.error')),    }),    [t],  );}

Then at every grid call-site:

TSX
import {useGridCopyMeta} from '@/lib/grid/use-grid-copy-meta';
function MyGrid() {  const copyMeta = useGridCopyMeta();  return (    <ActiveGrid      gridId="my-grid"      data={data}      columns={columns}      meta={copyMeta}    />  );}

If the grid already passes other meta fields, spread:

TSX
meta={{ ...copyMeta, ...myOtherMeta }}

Pattern 3 — App-level wrapper component#

Max DRY. Create a thin <AppGrid> wrapper that injects copy-meta defaults:

TSX
// host/grid/app-grid.tsx'use client';import {ActiveGrid, type GridProps} from '@workspace/active-grid';import {useGridCopyMeta} from './use-grid-copy-meta';
export function AppGrid<TData>(props: GridProps<TData>) {  const copyMeta = useGridCopyMeta();  return <ActiveGrid {...props} meta={{...copyMeta, ...props.meta}} />;}

Then in every grid replace <ActiveGrid> with <AppGrid>:

TSX
import {AppGrid} from '@/lib/grid/app-grid';
<AppGrid gridId="my-grid" data={data} columns={columns} />;

Most app-wide. Best when you have many grids and want a single point of control for cross-grid instance defaults beyond just copy (e.g. default height). Presentation, translator and behaviour configuration do not need a wrapper: they come from <GridDefaults> (see ../api/columns.md § GridDefaults).

Pattern 4 — Centralized event-bus / log-only#

If you don't want toasts but want telemetry, point the callbacks at your log helper:

TSX
import { logEvent } from "@/lib/telemetry";
meta={{  onCopySuccess: (count) => logEvent("grid.copy.success", { count }),  onCopyError: () => logEvent("grid.copy.error", {}),}}

Or use both — toast AND log:

TSX
meta={{  onCopySuccess: (count) => {    toast.success(t("grid.copy.success", { count }));    logEvent("grid.copy.success", { count });  },  onCopyError: () => {    toast.error(t("grid.copy.error"));    logEvent("grid.copy.error", {});  },}}

When the callbacks are not provided#

Copy still works — data goes to the clipboard via navigator.clipboard.writeText(). The user just doesn't see UX feedback. For external adopters who manage UX globally (e.g. via an error-boundary or a global toast-bus), this is the cleanest default.

Interaction with row selection#

Cell selection and row selection are independent. Both can be active simultaneously: a row can be selected (checkbox) and a cell-range within that row can also be selected. Operations that need an explicit choice (e.g., bulk delete) operate on row-selection. Ctrl+C copies an explicit cell range if there is one, otherwise the selected rows, otherwise the active cell: a clicked cell is the active cell and not a range, so the selected rows go before it. The context menu names both scopes as separate items. Opening the menu never changes the row selection.

Limitations#

  • Cell selection is client-state only — no URL-sync, no localStorage persistence.

  • The range cannot span across paginated boundaries.

  • For server-mode grids, only the currently-loaded rows can be in the range.

  • The selection is internal: there is no controlled prop and no imperative API to set it.

  • No paste (Ctrl+V) and no fill handle or drag-to-fill: a range can be copied, not written. Both are open roadmap work.

Known Limitations#

  • No column-header navigation: keyboard navigation operates within the body grid only. Header cells are not part of the navigation grid. Their controls — the sort label, the column menu, the filter row's inputs, funnel and clear button — are reached with Tab and keep their own native keyboard behaviour; the grid does not intercept it.

  • No Tab-between-cells: Tab does not move between cells. Tab moves to the next focusable element in the page (pagination, next component). Use arrow keys for cell-to-cell navigation.

  • Frame delay: scroll-to-focus in virtualized grids waits for the target cell over animation frames (up to fifteen). During very fast arrow-key repeats, focus may briefly lag behind the visual scroll position.

This page as Markdown