Manual UI Behaviors gridFocus

gridFocus

elements man ui/behaviors/grid-focus

One Tab stop for a two-dimensional grid of cells, arrows between them by row and column: the grid pattern for a calendar or a data table. The map is built from the DOM and kept until the grid changes, so a key in a thousand-row table costs one cell's work, not the table's.

Signature
function gridFocus(grid: HTMLElement, opts?: GridFocusOptions): GridFocus; interface GridPosition { row: number; col: number; }

Options

Option Type Default What it does
rows string "[role=row], tr" Selector for rows.
cells string "[role=gridcell], [role=columnheader], [role=rowheader], td, th" Selector for cells inside a row.
wrap boolean false A row's end wraps to the next row's start, as in a layout grid.
wrapColumns boolean false Down from the last row goes to the top of the next column, Up from the first row to the bottom of the previous one.
pageRows number 0 (off) PageUp and PageDown move this many rows when there is no onPage.
focusDisabled boolean false Disabled cells stay focusable.
editKeys boolean true Enter and F2 move into a cell's widgets, Escape and F2 come back.
dir "ltr" | "rtl" the grid's computed direction Reading direction. Left and Right flip under rtl.
current () => HTMLElement | null the first cell The cell that holds the tab stop when focus is outside: the selected day, today.
onPage (dir: 1 | -1, e: KeyboardEvent, cell: HTMLElement) => Next none PageUp (-1) or PageDown (1): a calendar changes month.
onEdge (dx: number, dy: number, e: KeyboardEvent, cell: HTMLElement) => Next none An arrow pressed with no cell that way; dx and dy are -1, 0 or 1 in visual direction. A calendar shows the next month.
onFocusCell (cell: HTMLElement, pos: GridPosition) => void none A cell received focus by keyboard.

onPage and onEdge may return the element to focus, or a function that returns one; the function runs on the next frame, after a reactive re-render has drawn the new month.

Keys

Keys Action
Arrows One cell. At an edge, onEdge; otherwise focus stays.
Home, End First and last cell of the row; of the grid in a one-column grid. Never call onEdge.
Ctrl+Home, Ctrl+End First cell of the first row, last cell of the last row. Cmd works too.
PageUp, PageDown pageRows rows, or onPage.
Enter, F2 Enter a cell that holds widgets: focus moves to its first widget and arrows belong to the widget.
Escape, F2 Back to the cell from an entered one.
Arrows in an entered cell Right and Down to the next widget, Left and Up to the previous, unless the focused widget uses arrows itself.
Tab in an entered cell The next widget in the grid, entering the cell that holds it; out of the grid after the last one.
A letter or digit On a cell whose first widget is a text field: enters it and types there.

Cells spanning several slots (colspan, rowspan, aria-colspan, aria-rowspan) are one stop, and the column you came from is remembered, so moving down past a wide cell comes back to it. Empty slots and disabled cells are skipped in the direction of travel unless focusDisabled. Rows and cells placed in the grid with aria-owns are part of the map. A grid started while hidden takes its stop when it is shown. When the focused cell or its row is removed, focus moves to the nearest usable cell in the same place. Shift+Space, Ctrl+Space and Ctrl+A are left to the template; Shift+Arrow still moves focus, so a template extends a range from its own keydown or from onFocusCell.

What takes focus in a cell: the cell itself, or its single widget when it holds exactly one that needs no arrow keys (a link, a button, a checkbox, a radio, a switch), so Space acts on it at once. A widget that uses arrows itself (a text field, a select, a slider, a spinbutton, a radio group, a combobox) is reached with Enter or F2, even alone. Clicking into a widget of a multi-widget cell enters that cell, so Escape and F2 return to it.

The handle

Member What it does
matrix() The cell slots: matrix()[row][col] is the cell covering it, or null.
current() The cell that holds the tab stop.
focus(cell) Moves the tab stop to cell (or the cell containing it) and focuses it.
position(cell) The row and column of a cell's first slot, or null.
sync() Re-applies tabindex after the cells changed. Runs on its own on DOM changes.
release() Stops and puts back the tabindex of every cell and widget it changed.

In a template of your own

app/shared/templates/month-grid/index.ehtml
import { gridFocus, type GridFocus } from "@elements/ui/behaviors"; interface MonthGridState { g?: GridFocus; } <MonthGrid (weeks: Date[][], selected: Date, onPick: (day: Date) => void, private ui: MonthGridState = {})> <table role="grid" oninsert={(e) => ui.g = gridFocus(e.target as HTMLElement, { current: () => (e.target as HTMLElement).querySelector("[aria-selected=true]"), })} onremove={() => ui.g?.release()}> <tbody> <tr e:for={week of weeks}> <td e:for={day of week} role="gridcell" aria-selected={day.getTime() === selected.getTime() ? "true" : "false"} onclick={() => onPick(day)}>{day.getDate()}</td> </tr> </tbody> </table> </MonthGrid>

Tab lands on the selected day, arrows move through the month, and the template keeps its own click handler for picking.

Writes

tabindex on cells and on the widgets inside them, put back on release; data-editing on an entered cell.

Used by

DataTable (opens in a new tab).