Data grid.
The centrepiece of every estimate screen, and the one component in Sprout where focus and selection have to coexist without confusing each other. A grid is not a table: every cell in it can be the one the keyboard is standing on, and any number of rows can be marked for a bulk action at the same time.
Row 4 is selected, and the Qty cell on row 2 is active. Two tokens, and one channel each: containers/primary draws the border that means focus, states/primary/selected fills the row that means selection. The active cell has no fill of its own, so it shows whatever its row shows, and the two states stay readable when they land on top of each other.
Anatomy
Six parts. The gutter and the header frame the data, the cell is where the work happens, and the last two are state rather than structure: they mark where you are and what you have picked.
Focus and selection have to stay visually separate, because they answer different questions: where am I typing and what will the toolbar act on. One cell is active, any number of rows can be selected, and both can be true of the same row at once. They share the hue and that costs nothing, because they no longer share a channel: focus is a border, selection is a fill, and each variable gets one channel to itself. That is the split Excel, Sheets and AG Grid all use.
Density
Two row heights, and the choice is about how many rows fit, not how the grid looks.
Dense changes the row and the cell padding, and nothing else. The value stays 13px, the header stays 13px Bold, and the hairline stays 1px. Shrinking the type as well is the tempting next step and the wrong one: at 24px rows the grid is already asking a lot of the eye, and smaller figures are what turns a fast scan into a misread quantity.
Keyboard
Forty lines of an estimate without touching the mouse. Every key below is a contract.
Esc reverts is the one with a build consequence: a cell has to hold its last committed value separately from what is on screen. That is the same displayed-against-stored split the numeric input page sets out.
No shortcut deletes a row. Destructive actions belong on a button someone has to look at.
States
Everything the grid can be doing, in one demo. The detail is underneath, a block per state.
Surfaces and lines The grid at rest
Hover fills the row, not the cell, because it answers which row am I about to hit. The gutter keeps the header fill so the numbers stay a column.
The hairline is table line, not outline. outline is the border of an input field, and a grid drawn in it outlines every cell like an empty field waiting to be filled.
Active cell The keyboard caret
One per grid. Border on four sides and no fill, so it stays legible on a plain row, a hovered row and a selected row alike.
No fill is deliberate. Excel, Sheets and AG Grid all leave the active cell unfilled: a fill would compete with selection for the same channel. Border means focus, fill means selection.
Selection One fill, four shapes
Same fill whatever the shape. What changes is how much of the border gets drawn.
Focus and selection share the hue and that is fine, because they no longer share a channel. A row can be selected and hold the active cell at once, which is the normal state rather than an edge case.
The fill is faint by design, so it tints a row without competing with the figures on it. The border is the part that survives a glance.
Error cell Per cell, on commit
No room for a message under a 44px row, so the border carries it and the sentence goes in a summary above the grid.
The cell still needs an accessible description carrying the same sentence. A red border alone says a value is wrong without saying why.
Error and active can both be true. Error takes the border, the selection fill stays.
Read only Reachable, not editable
A calculated total, or a line locked after approval. It takes focus and shows the active border, then swallows the keystroke.
Skipping these in the Tab order looks tidy and breaks copying a column. The one place a low ratio is correct: 1.4.3 exempts an inactive control.
It is neutral container, not surface variant, because the header took that one and a read only cell matching the header reads as a second header.
Figures and locale
A grid is a column of figures read against each other, so everything the numeric input page says about separators applies here with more force. One misplaced decimal in a column of forty is the failure mode this section exists to prevent.
One number formatter for the whole grid, driven by the user's locale, and never a value formatted in the markup. The demo above is nl-BE: € 4.218,00 and 12,40. The same rows in sv-SE would read 4 218,00 kr and 12,40. Both are correct; a grid that hard codes either is not.
Do & don't
Four decisions that come up every time someone builds one.