Add Grid with Composite span navigation
Motivation
Apps need a data table whose arrow keys follow cells that cover several rows or columns. Composite already supports 2D movement, but its current item positions do not describe those spans or remember where focus entered a merged cell. Extend that shared behavior, then add Grid parts for native table structure and semantics. Keep data processing with the app.
Status and scope
This issue is the canonical contract for Grid and Composite span support. It records the accepted design choices as D1 through D26. Earlier mode proposals are superseded: the accepted shape extends existing Composite 2D behavior, adds no public navigation switch, and keeps that switch out of the accepted names.
The interactive design page remains an example surface. Access to that page and its downloadable evidence may require the maintainer's private Site access. The contract, examples, and release obligations below must be usable without it, and the load-bearing evidence is inlined under Research and evidence so no accepted decision depends on that access.
A design audit against current main re-verified every decision, corrected the sections listed under Research and evidence, withdrew D18, and added D22 through D26. Decisions keep their original numbers: D18 is retained as withdrawn rather than reused, and new decisions continue from D22.
This is a design handoff, not a claim that the APIs have shipped. Publishing this issue does not authorize library implementation in the current task. Later contract changes belong in this issue body; the page should link here and keep its examples and feedback controls current.
Accepted decisions
| Decision | Accepted contract |
|---|---|
| D1 | Own focus and structure. Sorting, filtering, selection, records, and data sources stay with the app or a data library. |
| D2, revised | Extend existing Composite 2D behavior. Span sizes and private entry memory do not require a new public mode. Preserve current behavior for ordinary 1 × 1 items, including focusShift and orientation. |
| D3, revised | Every write to the active ID that is not move resets entry to the target cell's first logical row and column. That covers a click, a repeat click on the active cell, a touch, pointer hover, focus arrival, and the controlled activeId prop. Pixel position does not choose the entry slot. |
| D4 | Ship React first. Solid follows after its Composite foundation and adapter tests. Shared core logic alone does not provide a Solid Grid. |
| D5 | Include rowSpan=0 in the first release. It reaches the end of its own row group. Resolve a separate positive height for navigation. |
| D6 | Use explicit row and group parts. CompositeRow supplies row records; CompositeRowGroup supplies group identity; GridRowGroup uses the same ownership. |
| D7 | Arrows remember the incoming column for vertical moves and the incoming row for horizontal moves. One arrow leaves the whole span. |
| D8 | Add GridCellFocusTarget for one button or link. Render composition adds no wrapper. The cell owns geometry; the target owns its focus and key behavior. |
| D9 | A changed logical layout resets entry to the start of a surviving active cell. A new array or unrelated rerender does not. |
| D10 | Infer ordinary entry in the existing move(id) method. Keep queries pure. Add no separate public navigation or reset command. |
| D11, revised | With a complete map and no supplied active ID, Grid enters at its first eligible cell. Explicit IDs and null override this default. Grid manages the root's focusability from map emptiness, and the move from an empty map to the first eligible cell is explicit rather than left to the store's assign-only sync. |
| D12 | Integrate the shared Composite removal work in #1672 before release. Add no Grid-only removal API or destination rule. This accepts the dependency, not the whole issue proposal or a claim that it is finished. |
| D13, revised | Keep the listed Grid/Composite part, hook, store, type, and span names below. Remove navigation from this name set. Detailed signatures remain a sketch. |
| D14 | Wholly ungrouped Composite rows share one implicit logical group. Add no DOM wrapper or hidden tbody. Reject mixed explicit groups and ungrouped rows. |
| D15, revised | Report an invalid map in development, and degrade to unspanned row and column navigation instead of pausing. Four checks report on the first published map; two depend on completeness and report on the first published map that leaves it unchanged. Keep content and DOM focus where possible. Incomplete registration is a wait state, not an input error. |
| D16 | First-release Grid output uses native tables with complete rendered groups. Arbitrary CSS-hidden rows, custom Grid output, and virtualized spans are outside this promise. |
| D17 | Define and test loop, wrap, virtual focus, page movement, and existing single-axis behavior before release. Scope acceptance does not prove their implementations. |
| D18, withdrawn | The activeId-keyed disabled exception is withdrawn. #7359 closed as not planned and #7362 closed unmerged: keying on activeId regresses #3232, and the DOM-focus alternative measured +10% to +31% scripting on the hottest roving-navigation path. Grid inherits Composite's disabled behavior unchanged. The number is retained and not reused. |
| D19 | Add nested GridRenderer with all supplied groups, rows, and cells mounted in native table flow. Reuse the existing items/function-child shape; no new renderAll prop is required. |
| D20 | PageUp/PageDown leave the whole span and search along the remembered column, even when the move exceeds one viewport. At an edge with no target, keep the cell and entry. |
| D21 | Keep current orientation behavior. Items with rowId enable both arrow axes and row-based Home/End. Flat composites keep their current orientation rules. Add no new axis filter or shape restriction. |
| D22 | Grid sets role="grid" on its rendered element by default, and authors can override it. The other parts add no explicit ARIA role and rely on the HTML-AAM mapping that the ancestor role activates. Generic Composite consumers gain no grid roles. |
| D23 | Composite parts consume colSpan and rowSpan for geometry and re-emit them as native attributes only when the resolved element is a table cell. A span authored on a render element wins over the component prop, and a disagreement between the two is a reported invalid map. |
| D24 | Remove the deprecated numeric skip overload on next, previous, up, and down in this release. Home/End and page movement move to a span-aware query that selects by slot rather than by rowId. |
| D25 | Reconcile the surfaces #7114 also claims, the move signature and GridRow, before either design ships. |
| D26 | D10's rejected-target rule is a release prerequisite tracked in #5695, alongside the removal work in #1672. |
The accepted extension must not add grid roles to generic Composite consumers. A plain native table remains the smaller choice when users only read data and use a few controls. Compare the layers and current 2D behavior.
Non-goals are a data engine, automatic merging of equal values, selection ranges, clipboard rules, formulas, resizing, pinning, tree grids, server data loading, edit transactions, and unrestricted virtualization. Span props describe structure; they must not clear or change app values. Try app-owned sorting and filtering.
API surface
Accepted names are GridProvider, useGridStore, createGridStore, Grid, GridRowGroup, GridRow, GridCell, GridColumnHeader, GridRowHeader, GridCellFocusTarget, GridRenderer, CompositeRowGroup, existing CompositeRow, colSpan, rowSpan, and the matching hooks and types. No navigation prop, navigation store state, or public preferredColumn state is added.
Keep shared geometry and movement in the framework-independent Composite layer. Grid adds native table structure, semantics, and defaults. React needs the component exports, facade exports, and subpath exports together. Preserve supported React 17/18/19 behavior; implement and test Solid later. Exact hook/type signatures and renderer forwarding are implementation drafts, not additional accepted API decisions.
This is an API sketch, not a released import. It shows the accepted shape and valid native row-group markup:
<GridProvider>
<Grid aria-label="Team report">
<GridRowGroup render={<thead />}>
<GridRow>
<GridColumnHeader>Name</GridColumnHeader>
<GridColumnHeader>Status</GridColumnHeader>
<GridColumnHeader>Action</GridColumnHeader>
</GridRow>
</GridRowGroup>
<GridRowGroup>
<GridRow>
<GridRowHeader>Ada</GridRowHeader>
<GridCell>Ready</GridCell>
<GridCell>
<GridCellFocusTarget render={<button />}>
Open Ada
</GridCellFocusTarget>
</GridCell>
</GridRow>
<GridRow>
<GridCell colSpan={3}>Team total</GridCell>
</GridRow>
</GridRowGroup>
</Grid>
</GridProvider>Default element proposals are table for Grid, tbody for GridRowGroup, tr for GridRow, td for GridCell, and th with scope="col" or scope="row" for the header parts. Grid sets role="grid" on the table by default and the other parts set no explicit role, so td maps to gridcell, th to columnheader or rowheader, tr to row, and the sections to rowgroup through HTML-AAM. That role is what makes aria-activedescendant defined for virtual focus and aria-disabled supported on a cell; a plain table role supports neither, and a headerless table with no role can be demoted to a layout table. Allow explicit header associations for grouped headers. Keep a native caption; no separate caption component is needed. Require an accessible name. Static data remains focusable. Non-action headers may be excluded explicitly, but a disabled action must not make its whole data cell unreadable. Try cell controls and header associations.
Existing Composite already understands rows. Extend its item data with span sizes instead of adding a mode:
// Proposed span metadata on existing Composite parts.
// The app supplies any roles, native elements, and matching CSS.
<CompositeProvider>
<Composite>
<CompositeRow>
<CompositeItem>A1</CompositeItem>
<CompositeItem>B1</CompositeItem>
<CompositeItem>C1</CompositeItem>
</CompositeRow>
<CompositeRow>
<CompositeItem colSpan={3}>Notice</CompositeItem>
</CompositeRow>
<CompositeRow>
<CompositeItem>A3</CompositeItem>
<CompositeItem>B3</CompositeItem>
<CompositeItem>C3</CompositeItem>
</CompositeRow>
</Composite>
</CompositeProvider>Preserve legacy rowId composition for ordinary items. Non-default span geometry and rowSpan=0 need explicit row records and matching group ownership, with the implicit group allowed by D14. Merely authoring colSpan={1} or rowSpan={1} must not trigger stricter ownership validation for an otherwise-valid 1 × 1 Composite. Do not infer this extra structure by scanning arbitrary DOM. Removing the mode switch must not apply new span-specific row/group validation to otherwise-valid existing unspanned Composites. Support for spans authored only with manual rowId would need an adapter contract; it is not part of the accepted explicit-parts path.
Composite parts consume colSpan and rowSpan for geometry. They re-emit the native attribute only when the resolved element is a table cell, so a span on an item rendered as a div or a button never reaches the DOM as an invalid colspan. When a component prop and a render element both carry a span, the render element wins and the disagreement is reported rather than resolved silently. Declaring these props on CompositeItem adds them to every component built on it, including ComboboxItem, SelectItem, MenuItem, ToolbarItem, Tab, Radio, Tag, and TagInput; their reference pages must say the props are inert outside a table cell.
Cell geometry and entry memory
A cell is one rectangle with one stable ID. A slot is one row/column position covered by that rectangle. Build the map from row order and item order: put each cell at the first free position, then reserve every slot covered by its spans. A short row does not imply a merge. Omit separate cells for covered slots. Do not use pixel sizes to discover logical columns. Change spans and invalid layouts.
For example, Notice occupies all three positions in row 2:
A1 B1 C1
[ Notice ]
A3 B3 C3
C1 → Down → Notice → Down → C3
C1 → Down → Notice → Up → C1
Click Notice → Down → A3The private position contains the active cell ID and its entry row/column. Vertical movement searches beyond the whole top or bottom of the current rectangle while keeping the incoming column. Horizontal movement searches beyond the whole left or right edge while keeping the incoming row. Enter the new cell at the crossed edge. A failed search changes neither the ID nor entry. A turn uses the most recent entry edge, not the history of earlier moves.
For a tall cell in column B covering rows 2–4, A3 → Right → Team → Right → C3 keeps row 3. A3 → Right → Team → Down → B5 → Up → Team → Left reaches A4 because Up entered through row 4. Try both routes.
D7 does not approve keeping a missing column through a narrow 1 × 1 cell. Preserve current ordinary-item focusShift routes. The draft span extension uses the existing predecessor-style fallback for a missing or excluded vertical target, then clamps entry inside the chosen rectangle. Do not substitute the older nearest-cell experiment. Exact mixed-span focusShift behavior needs the compatibility tests below before implementation can be called complete. Compare source routes and the span extension.
Use physical Left/Right with RTL, reversing once while retaining logical column order. Set matching HTML/CSS direction. The default Grid has real DOM focus, no loop, no wrap, and no shift.
Rows, groups, zero, and validation
Each group has its own identity. Two tbody elements are two groups, even if both are body sections. Each row registers independently of its cells, takes its group from the nearest matching group part, and belongs to the same Composite store. Each cell takes its row from the matching row part. Nested composites have separate maps; existing CompositeGroup does not gain a new navigation meaning.
Wholly ungrouped Composite rows have one implicit logical group without adding an element. Native Grid authors still supply a real table section, such as GridRowGroup rendering tbody. This avoids HTML parser-inserted sections that disagree with the React tree. Try group ownership and zero spans.
The draft numeric contract uses whole-number colSpan values from 1 through 1000 and rowSpan values from 0 through 65534, both defaulting to 1. Negative, fractional, non-finite, and out-of-range values are invalid. Zero is valid only for rowSpan. Keep the authored zero in native output and resolve its positive height before overlap checks or navigation:
const effectiveRowSpan =
rowSpan === 0 ? group.rows.length - originRowIndex : rowSpan;A zero span includes its origin row and all remaining rows in that group. It grows when rows are added after its origin in the same group, not when rows are added to another group. At the last row its height is 1. Empty groups have no span owner. Group ends limit span height, not navigation into a following group. HTML spans and ARIA zero spans define the group-end meaning.
Keep row order, group membership, and cell rectangles in one complete snapshot. Refresh it after insertion, removal, reorder, span changes, or group changes before the next query or move. D9 resets entry to a surviving active cell's new start. Do not reset it for a text update, a new array containing the same logical map, or an unrelated rerender. A structural update must not steal focus from an outside control. Disabling alone does not change the rectangle.
For the declared span map, reject overlapping rectangles, duplicate IDs, nested groups within one composite, mixed explicit and ungrouped rows, rows that contribute no cell and are covered by no span, and positive spans that exceed the declared group. A row fully covered by a span is valid and is the only correct markup for a cell that covers every column of the rows below it: omitting that row instead pushes the following cells into new columns. Keep row records so invalid rows are reported rather than silently removed. Positive overflow rejection is an Ariakit policy; browsers clamp an overflowing rowspan to its row group and grow the table sideways for an overflowing colspan.
Report an invalid map in development. Four checks are decidable before registration settles, because no later item can make them valid, and they report on the first published map: duplicate IDs, overlapping rectangles, out-of-range span values, and mixed explicit and ungrouped rows. The other two depend on completeness and report on the first published map that leaves the map unchanged: a row that contributes no cell and is covered by no span, and a positive span that exceeds its group.
An invalid map degrades to unspanned row and column navigation rather than pausing. This revises the earlier rule that navigation must not fall back to old item indexing: a keyboard that stops responding in a production build, with no visible signal, no console output, and no change in the accessibility tree, is the worse failure. These new map checks must not invalidate legacy unspanned Composite composition.
The proposed first ownership limit is committed DOM order within one root, with stable IDs across server/client output and keyed reorders. Cross-root portals and conflicting stores need an explicit adapter contract rather than guessed ownership. Exact diagnostic APIs and registration timing remain implementation details. HTML table model.
Queries, moves, and controlled state
Queries such as down(), up(), next(), previous(), first(), and last() stay pure and return IDs. They do not save a candidate lane as a side effect. Ordinary move(id) infers entry from the current position and target rectangle:
- The same ID keeps the current entry.
- A target above or below that covers the saved column inherits that column and enters through its near vertical edge.
- A target to either side that covers the saved row inherits that row and enters through its near horizontal edge.
- An unrelated target starts at its own origin.
- A click uses the separate D3 reset path, even for the same active cell.
const id = store.down(); // Query only.
store.move(id); // Infer entry from current state and target.
store.move("Notice"); // An aligned app call inherits entry too.
// No store.navigate(), direction overload, or new public reset method.Home/End, Control+Home/End, wrap, and shifted moves can need internal keyboard intent that an ID alone cannot express. Keep that intent private. An app call move(first()) need not choose the same entry slot as Control+Home. Preserve move(undefined) as a no-op, move(null) as root entry, an
Source: ariakit/ariakit