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
This guide is the package's docs/, 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
../.
<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#
<ActiveGrid enableKeyboardNavigation={false} />This restores the pre-8.0.0 behaviour: the grid element gets no tab,
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 (edit and
"double) hang off on / on, and a <div role="gridcell">
does not synthesise a click from Enter.
It works the same way in all three editing modes:
edit | Enter on an editable cell |
|---|---|
cell | Opens that cell's editor |
row | Opens the whole row for edit |
batch | Opens that cell's editor |
on 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#
on and on fire regardless of keyboard navigation.
Before 8.0.0 enabling navigation silently suppressed both, along with the
cursor- affordance. That exclusion is gone: a row click and a cell
focus are events at different levels.
Keyboard Shortcuts#
Grid Navigation#
| Key | Action |
|---|---|
| Arrow Right | Move focus to next cell |
| Arrow Left | Move focus to previous cell |
| Arrow Down | Move focus to cell below |
| Arrow Up | Move focus to cell above |
| Home | Move focus to first cell in row |
| End | Move focus to last cell in row |
| Ctrl+Home | Move focus to first cell in first row |
| Ctrl+End | Move focus to last cell in last row |
| PageDown | Move focus down by one viewport height |
| PageUp | Move focus up by one viewport height |
| Enter | Activate focused cell (triggers on) |
| Space | Activate focused cell (triggers on) |
| Shift+F10 | Open the row context menu for the active cell |
| ContextMenu | Open the row context menu for the active cell |
| Ctrl/Cmd+C | Copy: the cell range, else selected rows, else the active cell |
| Ctrl/Cmd+A | Select all rows (not cells) |
| Ctrl/Cmd+Z | Undo the last recorded cell edit (when undo/redo is on) |
| Ctrl/Cmd+Y | Redo the last undone cell edit (when undo/redo is on) |
| Ctrl/Cmd+Shift+Z | Redo, as Ctrl/Cmd+Y |
Ctrl/Cmd+Z and Ctrl/Cmd+Y act only while undo/redo is on, which is the default
whenever on is set (enable), and not in
edit. 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..
Initial Focus#
When the grid element itself is focused (via Tab) and no cell has focus:
| Key | Action |
|---|---|
| Arrow Down | Focus the first cell (row 0, column 0) |
| Arrow Right | Focus the first cell (row 0, column 0) |
| Enter | Focus 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. (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
tab and action mode reaches them:
| Key | In action mode |
|---|---|
| Enter / Space on cell | Focus the cell's first control |
| Space / Enter | Operate the control (toggle the selection, expand) |
| Tab / Shift+Tab | Next / previous control in the cell; past the last, back |
| Escape | Leave 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-, and the row is aria- 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- (the native proxy input of a checkbox
primitive), and a control with aria-.
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
tab; action mode reaches it all the same.
With enable 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):
| Key | Action |
|---|---|
| Enter | Commit the edit and move to next editable cell in the same column |
| Escape | Exit 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
(is, or key). 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 Cell:
interface CellPosition { rowIndex: number; colIndex: number;}Visual Indicator#
The active cell receives:
tab(other cells haveIndex={0} tab), 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 (Index={-1} on), so the two agree from the first frame, also when the pointer is dragged on.Mouse Down data-data attribute.focused="true" One square mark: one pixel at 50% of
--ringon all four sides, drawn fromdata-and the grid'sfocused data-, not from the browser'sbody- focus :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- 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-), 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..
Cell Registration#
Each cell registers its DOM element via register. This allows the
keyboard handler to programmatically focus the correct element when
navigation occurs.
// 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:
The new
Cellis set in state.Position usechecks if the cell's DOM element exists viaKeyboard Navigation cell.Refs If the element exists:
element.+focus() scroll.Into View({block: 'nearest'}) If the element does not exist (virtualized off-screen):
Call
virtualizer.to scroll the target row into view.scroll To Index(center Row Index, {align: 'auto'}) 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 scroll from the virtualizer via a ref threaded
through the grid's view layer. The absolute 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. scroll To Index#
Previous versions estimated scroll position as row,
which assumed fixed row heights. This broke after ~50 rows with dynamic
heights (estimate, expanded rows, auto cells).
virtualizer. uses the virtualizer's internal measurement
cache for accurate positioning regardless of row-height variation.
The scroll 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:
pageSize = Math.floor(scrollElement.clientHeight / rowHeight)PageDown:
newRow = Math. min(current Row + page Size, last Row) PageUp:
newRow = Math. max(current Row - page Size, 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:
Skip link (visually hidden until focused).
Toolbar (search input, action buttons).
The grouping panel, when grouping is enabled.
The
role="grid"element itself.Column headers and the filter row.
The active cell, once there is one — and nothing else in the body.
Pagination (page buttons, page-size selector).
The tab stop is the role="grid" element. It is not the inner
.: a focusable element with no role sitting directly
inside role="grid" is an aria- violation, which axe rates
critical. Focus-visible styling sits on that same element, in the package
stylesheet (styles/): its own border at 50% of the ring, no ring.
.grid-frame--focusable:focus-visible { border-color: color-mix(in oklab, var(--grid-ring) 50%, transparent);}The skip link is an exit#
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 tab, 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:
| Element | ARIA Role |
|---|---|
| Grid container | role="grid" |
| Header row | role="row" |
| Header cell | role="columnheader" |
| Body row | role="row" |
| Body cell | role="gridcell" |
| Footer row | role="row" |
| Selection checkbox | aria- / "Select all" |
| Header checkbox | aria-: false / mixed (part of the page) / true |
| Expander toggle | aria- / "Collapse", aria- |
| Group toggle | aria- / "Collapse group", aria- |
| Sort indicator | aria- / "descending" |
| Empty-state message | One row with one gridcell holding the message |
| Body while loading | rowgroup with aria- |
What the grid says it can select follows what the reader can select:
aria- with enable, and with row
selection that is not limited to one row (enable with
enable 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- in every case.
Every body row carries aria-, 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
(show, or a filterable column with meta.); a grid
without a filter row reserves no place for one. aria- 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.
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..
| Control | Name (English defaults) |
|---|---|
| Filter-row value control | Filter value for {column} |
| ... with a chosen value | Filter value for {column}: {value} |
| Funnel button, and its popover | Filter {column} |
| Clear button | Clear filter {column} |
| Condition select in the popover | "Condition" |
| Value fields in the popover | "Value", or "From" / "To" for a range |
| Column menu button | Column options for {column} |
| Sort label in a multi-column sort | {column} sort order {n} |
| Cell editor | {column} |
| Server group row | shows the grouped column's label, not the field id |
The words come from the host's translator through the grid.* keys
(grid., grid.,
grid., grid.,
grid., grid., grid.,
grid., grid., grid.,
grid.); 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 item | Shown when | One activation |
|---|---|---|
| Move left | enable | One valid position to the left |
| Move right | enable | One valid position to the right |
| Increase width | enable, and the column allows it | 10 px wider, up to the column's maximum |
| Decrease width | enable, and the column allows it | 10 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 (
min,Size max, else the defaults), and a changed width locks the column exactly as the end of a drag does.Size 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-), its new width, or that it is at its minimum or maximum width. A pointer drag announces nothing.colindex
The words come from the host's translator: grid.,
grid., grid., grid.,
grid., grid., grid.,
grid. and grid., with the column
label, position, total and width as variables.
A column whose header menu is suppressed (meta. or
meta.) 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
enable, 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.Cell Selection Without
enableit is the row menu, as it always was.Cell Selection The row's actions stay. A source row keeps its own menu (the
contextbuilder, edit, pin, export, reset) in every context.Menu 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. row
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:
| Context | Items |
|---|---|
| range | Copy selection · Copy selection with headers |
| cell | Copy cell · Copy row · Copy row with headers · Copy selected rows (N) |
| row | Copy 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-, 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. instead of the grid when
closing. Use the restore prop to manually restore focus to the
last active cell:
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 handle does not fire (e.g., focus lands on
document. instead of the grid container).
Cell selection (range-select)#
Cell-range selection is off by default. Turn it on with enable;
it is independent of row selection (the checkbox column).
<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).
| Input | Active cell | Ranges |
|---|---|---|
| Click | The clicked cell | Ended; nothing is stored |
| Click on the active cell | Unchanged | Unchanged: nothing changes at all |
| Drag | The pressed cell (anchor) | One range from the anchor to the cell under the pointer |
| Shift+click | Stays (anchor) | One range from the anchor to the clicked cell |
| Shift+Arrow, Shift+Home/End | Stays (anchor) | The far end moves and is brought into view |
| Ctrl/Cmd+click or drag | The clicked cell | Added; the earlier selection, the active cell included, stays |
| Ctrl/Cmd+Shift+click | Stays (anchor) | The last range is replaced by one from the anchor to the cell |
| An arrow that moves the cell | Moves | Ended |
| Escape | Stays | Ended; without a range, nothing happens |
| Starting an edit | Stays | Ended |
| Ctrl/Cmd+C | – | Copies the selection (see below) |
| Right-click, Shift+F10 | See the menu section | See 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. on receives the explicit ranges,
Cell, 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-, and the grid declares aria-.
Only true is stated, so a row's own selection is never contradicted. The
active cell is a separate fact (data-, the one tab, and the
focus); the one-cell look of a clicked cell is focus, not selection, so it
carries no aria-.
Turning cell selection off ends the selection. When enable
goes from true to false, every range is cleared and
on reports [] once. Nothing of the range stays behind
out of sight: no cell is painted or aria-, 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..
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., 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. § 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:
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. on 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:
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:
// 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:
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:
meta={{ ...copyMeta, ...myOtherMeta }}Pattern 3 — App-level wrapper component#
Max DRY. Create a thin <App wrapper that injects copy-meta defaults:
// 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 <Active with <App:
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 <Grid (see
../ § GridDefaults).
Pattern 4 — Centralized event-bus / log-only#
If you don't want toasts but want telemetry, point the callbacks at your log helper:
import { logEvent } from "@/lib/telemetry";
meta={{ onCopySuccess: (count) => logEvent("grid.copy.success", { count }), onCopyError: () => logEvent("grid.copy.error", {}),}}Or use both — toast AND log:
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.. 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.