Task list
StableThe task list as a data grid: one Tab stop, arrows to move, pickers in the cells, and groups that collapse.
TaskGrid draws grouped task rows (TaskRow) as role="grid" with a roving tab stop, so a long list costs one press of Tab. You own the data: pass groups from groupTasks(), the selection, the active cell and the callbacks, and put it inside a PickerHost so the property cells can open their pickers. What opening a task does is up to you; TasksApp opens the detail view.
Grouped by status
import { useState } from 'react'import { sampleTasks } from '@rchursin/product/data/sample'import { DEFAULT_PREFS, groupTasks, labelIndex, PickerHost, TaskGrid, usePick, type ActiveCell, type PickChange, type Task } from '@rchursin/product/tasks'import { useToast } from '@rchursin/product/ui'
/** A fixed day, so the due dates read the same on every visit */const NOW = new Date(2026, 9, 13, 9, 30)const labels = labelIndex([])
/** Apply a picker change to the local list */function apply(tasks: Task[], ids: string[], change: PickChange): Task[] { return tasks.map((t) => { if (!ids.includes(t.id)) return t if (change.kind === 'labels') return { ...t, labels: [...t.labels.filter((l) => !change.remove.includes(l)), ...change.add.filter((l) => !t.labels.includes(l))] } return { ...t, [change.kind]: change.value } })}
/** * The list on its own copy of the sample tasks. One Tab stop: arrows or J and K move,Usage
import { DEFAULT_PREFS, groupTasks, labelIndex, PickerHost, TaskGrid, usePick } from '@rchursin/product/tasks'<PickerHost tasks={tasks} labels={labelIndex([]).all} onChange={applyChange}> <TaskGrid label="Tasks" groups={groupTasks(tasks, 'status', 'manual', false)} collapsed={collapsed} onToggleGroup={toggleGroup} now={new Date()} props={DEFAULT_PREFS.props} labelName={labelIndex([]).name} selected={selected} active={active} onActiveChange={setActive} onOpen={openTask} onSelect={toggleSelected} onExtend={selectRange} onPick={(kind, id, anchor, refocus) => pick({ kind, ids: [id], anchor, refocus })} /></PickerHost>API
TaskGrid props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | Accessible name of the grid. |
groupsrequired | TaskGroup[] | – | The rows to draw, from groupTasks(). Each group gets a header row unless flat is set. |
flat | boolean | false | No header rows: one group of everything. |
collapsedrequired | string[] | – | Keys of the collapsed groups. |
onToggleGrouprequired | (key: string) => void | – | A group header was pressed. |
onCreateIn | (group: TaskGroup) => void | – | Shows a “+” in each header. group.patch says what a task created there starts with. |
nowrequired | Date | – | Due dates are relative to it (“Today”, “Fri”, “Oct 21”). |
propsrequired | Record<PropKey, boolean> | – | Which properties each row shows. Narrow containers still drop labels, estimate and ID first. |
labelNamerequired | (id: string) => string | – | Turns a label id into its name: labelIndex(custom).name. |
selectedrequired | ReadonlySet<string> | – | Selected task ids. Any selection shows every checkbox. |
activerequired | ActiveCell | null | – | The cell holding the tab stop, { row, col }. null lands on the first task’s title. |
onActiveChangerequired | (cell: ActiveCell) => void | – | Focus moved to another cell. Store it and pass it back as active. |
onOpenrequired | (id: string) => void | – | Enter on a title, or a click on the row. |
onSelectrequired | (id: string, how: 'toggle' | 'range') => void | – | Space on a title, Shift+Space, the checkbox, ⌘/Ctrl-click (toggle) or Shift-click (range). |
onExtendrequired | (fromId: string, toId: string) => void | – | Shift+↑ or Shift+↓: select from the anchor to this task. |
onPickrequired | (kind: PickKind, id: string, anchor: HTMLElement, refocus: () => void) => void | – | A property cell was pressed. Open its picker next to anchor; pick() from usePick does exactly that. |
demoHover | string | – | Draw this task’s row as hovered (for specimens). |
className | string | – | Classes on the wrapper. Give it a height and overflow-y-auto so the group headers stick. |
ref | Ref<ViewHandle> | – | focusActive(), move(1 | -1) and focusTask(id, col?), for app-level keys like J and K. |
PickerHost props
| Prop | Type | Default | Description |
|---|---|---|---|
tasksrequired | Task[] | – | The tasks a request can name, for the pickers’ current values. |
labelsrequired | Label[] | – | Every label the labels picker offers. |
onChangerequired | (ids: string[], change: PickChange) => void | – | Apply a change to those tasks. Labels arrive as add and remove lists. |
onOpen | (req: PickRequest) => void | – | A picker opened. |
onClose | (req: PickRequest) => void | – | A picker closed. If its anchor is gone, refocus() runs. |
onCreateLabel | (name: string) => Label | – | Lets the labels picker create a label. |
childrenrequired | ReactNode | – | The view. Anything inside can call usePick(). |
usePick() request
| Prop | Type | Default | Description |
|---|---|---|---|
kindrequired | 'status' | 'priority' | 'assignee' | 'labels' | 'due' | 'estimate' | – | Which picker to open. |
idsrequired | string[] | – | The tasks it changes: one, or a whole selection. |
anchorrequired | HTMLElement | – | The popover sits next to it, and focus goes back to it on close. |
refocus | () => void | – | Where focus goes if the anchor is gone after the change (the row moved group). |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Down arrowUp arrow | Next or previous row, group headers included. |
| JK | Next or previous task, skipping headers. |
| Left arrowRight arrow | Previous or next cell in the row. |
| HomeEnd | First or last cell in the row. |
| CtrlUp arrowCtrlDown arrow | First or last row (⌘/Ctrl+Home and End too). |
| Enter | On a title: open the task. On a property: open its picker. On a group header: collapse or expand it. |
| Space | On a title: select or deselect the task. On a button: press it. |
| ShiftSpace | Select or deselect the task from any of its cells. |
| ShiftDown arrowShiftUp arrow | Extend the selection to the next or previous task. |
| Tab | Leave the grid: it is a single Tab stop. |
- role="grid" with aria-multiselectable. Each group is a rowgroup whose header toggle has aria-expanded; rows carry aria-selected.
- Exactly one cell has tabIndex 0 (a roving tab stop). It follows focus and falls back to the first task’s title.
- A cell that holds one control focuses the control itself. Property buttons are named with their value, like “Status: In review” or “Due date: Friday, October 16, 2026, overdue”, and have aria-haspopup="dialog".
- The title cell starts with the task id (hidden visually), so each row reads “RC-128, Fix test-fixer…”.
- Rows are 36px with 24px targets. As the container narrows, labels go first (under 672px), then the estimate (576px), then the ID (448px); the title stays.
Guidelines
Do
- Wrap the grid in a PickerHost (StorePickerHost for the shared store), so one popover serves every row.
- Keep active in state and pass it back, so the tab stop survives regrouping and re-renders.
- Give the scrolling wrapper scroll-pt-10, so a sticky header never covers the focused row.
Don’t
- Don’t rebuild the list as a table or a listbox: a listbox can’t hold buttons, and a table puts four pickers per row in the Tab order.
- Don’t hide the checkboxes on touch screens; they already show there because there is no hover.
In use
Related
- TasksThe whole task tracker: a keyboard-first list and board, a detail view, bulk edits, and undo for every change.
- Task boardA column per status, with cards moved by dragging, by keys on their grip or from a menu, and every move announced.
- Filter barLive search and multi-select quick filters for tasks, and the Display popover for grouping, ordering and properties.
- Bulk actions barA dark floating toolbar for a selection: the count, clear, five property pickers and delete.
- Property pickersThe Tasks tool’s pickers for status, priority, assignee, labels and estimate, as a chip, a row or an icon.
- Status & priorityTask glyphs told apart by shape: a square that fills as work moves on, priority bars, assignee, labels and estimate.
Source
The component’s own files, exactly as they run on this site.
/** * The issue list as an APG data grid. * * Why a grid and not a table or a listbox: every row holds pickers (priority, status, due date, * assignee), and a listbox can't contain interactive things. A plain table would put four * buttons per row in the Tab order (48+ stops for a short list). A grid is ONE Tab stop: * ↑ ↓ (or J K) move between rows, ← → between cells, Enter opens the task from its title, * Enter or Space on a property opens its picker, ⇧↑ ⇧↓ extend the selection. * The tab stop is "roving": exactly one cell has tabIndex 0, and it follows focus. * * Group headers are rows too (a toggle that collapses the group, and "+" to create in it). */import { useImperativeHandle, useRef, type FocusEvent, type KeyboardEvent, type Ref } from "react"import { cx } from "../../components/cx"import { Icon } from "../icons/Icon"import { isApple } from "../lib/hotkeys"import type { TaskGroup, PropKey } from "./model"import { AssigneeAvatar, PriorityIcon, StatusIcon } from "./parts"import type { PickKind } from "./PickerHost"import { TaskRow, type TaskCol } from "./TaskRow"