Skip to content
Tier

useKeyboardReview

Pro

Keyboard review for any list of rows a person keeps or drops: extracted fields, proposed edits, matched records, suggested tags. J and K (or the arrows) move between rows, Home and End go to the first and last, Y keeps, N drops (a second press clears the choice), and U undoes the last decision, moving back to its row. Nothing starts decided. The keys are strings, keyboardReview in LocaleProvider's strings, so a translation can pick ones that match its words, and each row carries them in aria-keyshortcuts. Keys with Ctrl, ⌘ or Alt held, and keys typed in a field inside a row, are left alone.

The list is one tab stop: the row last focused takes Tab, the others are reached with the keys. A decision belongs to the row as it was when the person made it: pass versionOf, and a row that changes afterwards is undecided again. The hook draws nothing; your rows show each decision, and your Apply sends kept.

const { listRef, rowProps, decisionOf, kept } = useKeyboardReview({ items: fields, idOf: (f) => f.id })
<ul ref={listRef}>
  {fields.map((f, i) => <li key={f.id} {...rowProps(i)} data-decision={decisionOf(f) ?? "open"}>…</li>)}
</ul>
Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0 and 2.9.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/use-keyboard-review

Brings in: locale, tokens

Usage

TSX · The example above
"use client"

import * as React from "react"
import { Check, Undo2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useKeyboardReview } from "@/lib/candor/use-keyboard-review"
import { Button } from "@/components/candor/ui/button"
import { Locked } from "@/components/candor/locked"

/*
 * Matches an agent proposed between payments and invoices, for a person to keep or drop from the keyboard. Click a
 * row (or Tab to the list), then J and K move, Y keeps, N drops, U undoes. Nothing starts kept, and Apply sends only
 * what was kept. The keys are strings: set them under `keyboardReview` in LocaleProvider's `strings`.
 */
type Match = { id: string; payment: string; invoice: string; basis: string }

const matches: Match[] = [
  { id: "m-1", payment: "Payment 4471 · 1,240.00", invoice: "INV-2207", basis: "Same amount and reference" },
  { id: "m-2", payment: "Payment 4472 · 318.50", invoice: "INV-2211", basis: "Same amount, reference differs" },
  { id: "m-3", payment: "Payment 4475 · 92.00", invoice: "INV-2190", basis: "Same customer, amount within 1%" },
  { id: "m-4", payment: "Payment 4478 · 5,000.00", invoice: "INV-2215", basis: "Same reference" },
]

export default function Example() {
  const review = useKeyboardReview({ items: matches, idOf: (m) => m.id })
  const [sent, setSent] = React.useState<string>()
  const { keys, listRef } = review
  return (
    <section aria-label="Proposed matches" className="flex max-w-xl flex-col overflow-hidden rounded-xl border bg-card">
      <p aria-live="polite" className="border-b px-4 py-2 text-xs text-muted-foreground tabular-nums">
        {review.kept.length} kept · {review.dropped.length} dropped · {review.open.length} to decide
      </p>
      <ul ref={listRef} className="divide-y">
        {matches.map((m, i) => {
          const d = review.decisionOf(m)
          return (
            <li key={m.id}>
              {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-noninteractive-element-interactions */}
              <div
                {...review.rowProps(i)}
                role="group"
                aria-label={`${m.payment} to ${m.invoice}`}
                className={cn(
                  "flex items-center gap-3 border-s-2 border-transparent px-4 py-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/80 focus-visible:ring-inset",
                  d === "keep" && "border-success bg-success-weak/30",
                  d === "drop" && "bg-sunken text-muted-foreground"
                )}
              >
                <span className="min-w-0 flex-1 text-sm">
                  {m.payment} → <span className="font-medium">{m.invoice}</span>
                  <span className="block text-xs text-muted-foreground">{m.basis}</span>
                </span>
                <span className="flex gap-1">
                  <Button size="xs" variant="outline" aria-pressed={d === "keep"} className="aria-pressed:border-success aria-pressed:bg-success-weak aria-pressed:text-success-text" onClick={() => review.decide(m, "keep", { toggle: true })}>
                    <Check aria-hidden /> Keep
                  </Button>
                  <Button size="xs" variant="outline" aria-pressed={d === "drop"} className="aria-pressed:bg-muted aria-pressed:text-foreground" onClick={() => review.decide(m, "drop", { toggle: true })}>
                    <X aria-hidden /> Drop
                  </Button>
                </span>
              </div>
            </li>
          )
        })}
      </ul>
      <footer className="flex flex-wrap items-center gap-2 border-t bg-sunken px-4 py-2">
        <span className="me-auto text-2xs text-faint-foreground">
          <kbd className="font-sans">{keys.next}</kbd>/<kbd className="font-sans">{keys.previous}</kbd> move · <kbd className="font-sans">{keys.keep}</kbd> keep ·{" "}
          <kbd className="font-sans">{keys.drop}</kbd> drop · <kbd className="font-sans">{keys.undo}</kbd> undo
        </span>
        {review.canUndo && (
          <Button size="sm" variant="ghost" onClick={review.undo}>
            <Undo2 aria-hidden /> Undo
          </Button>
        )}
        <Locked reason={review.kept.length === 0 ? "Keep at least one match to apply." : undefined}>
          <Button size="sm" onClick={() => setSent(review.kept.map((m) => m.id).join(", "))}>
            Apply {review.kept.length} kept
          </Button>
        </Locked>
      </footer>
      {sent && <p className="border-t px-4 py-2 text-xs text-muted-foreground">Sent to the server: {sent}</p>}
    </section>
  )
}

Functions

useKeyboardReview({ items, rows, idOf, versionOf, locked = false, keys }: KeyboardReviewOptions<T>)

Strings

The fixed words, in English by default. Change any of them under keyboardReview in <LocaleProvider strings={{ … }}>; the table is exported as keyboardReviewStrings.

KeyEnglish
nextKeyJ
previousKeyK
keepKeyY
dropKeyN
undoKeyU

Types

ReviewDecision
A person's decision on one reviewable row. A row with neither is undecided.
"keep" | "drop"
KeyboardReviewKey
The keys `useKeyboardReview` listens for.
"next" | "previous" | "keep" | "drop" | "undo"
KeyboardReviewOptions
What `useKeyboardReview` takes.
{ /** Every row that can be decided, in order, shown or not. The counts and `kept` cover these. */ items: readonly T[]; /** The rows on screen, in order, that the keys move through: a page or a filter of `items`. Defaults to `items`. */ rows?: readonly T[] | undefined; /** A row's id, stable for that row. Decisions are kept by it. */ idOf: (item: T) => string; /** * What the person decided on, as a string (the row's JSON, a revision, a hash). A decision made on one version is * cleared when the row changes, because it was made on something else. Left out, a decision stays with the id. * Worked out once for each `items` and `versionOf`: pass a function that keeps its identity (a module function, or * one from useCallback), or every render works each row's version out again. */ versionOf?: ((item: T) => string) | undefined; /** Decisions can't change (the kept rows are being applied): the keys, `decide` and `undo` do nothing. */ locked?: boolean | undefined; /** * Keys for this one list, in place of the strings: a string replaces that key, `null` turns it off. A component * with its own words passes its own keys here (ChangeSetReview passes its K and D, and turns off J, K and undo). */ keys?: Partial<Record<KeyboardReviewKey, string | null>> | undefined; }
KeyboardReviewRowProps
The props `rowProps` gives each row: spread them on the element that takes focus.
{ tabIndex: 0 | -1 "data-review-row": "" "aria-keyshortcuts": string | undefined onFocus: () => void onKeyDown: (e: React.KeyboardEvent) => void }
KeyboardReview
What `useKeyboardReview` gives back.
{ /** The row's decision, or undefined when it's undecided (or was decided on a version that has since changed). */ decisionOf: (item: T) => ReviewDecision | undefined; /** The kept rows, in the order of `items`. */ kept: T[]; /** The dropped rows, in the order of `items`. */ dropped: T[]; /** The rows with no decision, in the order of `items`. */ open: T[]; /** * Keeps or drops one row or several (Keep all on this page). With `toggle`, a row that already has that decision is * cleared instead, as a second press of a key does. Each call is one step for `undo`. */ decide: (which: T | readonly T[], decision: ReviewDecision, options?: { toggle?: boolean | undefined; } | undefined) => void; /** Puts the decisions back as they were before the last `decide` that changed something. */ undo: () => void; /** Whether there is a decision to undo. */ canUndo: boolean; /** The index in `rows` of the row that takes Tab (the one last focused). */ active: number; /** Sets the row that takes Tab without moving focus, as when a page turns. */ setActive: (index: number) => void; /** Moves focus to the row at `index` in `rows`. */ focusRow: (index: number) => void; /** * Put it on the element that holds the rows, so the keys can move focus between them. Take it out of the result * first (`const { listRef } = review`): lint rules for refs treat an object passed as `ref` as a ref throughout. */ listRef: (element: HTMLElement | null) => void; /** The props for the row at `index` in `rows`: one tab stop for the list, the keys, `aria-keyshortcuts`. */ rowProps: (index: number) => KeyboardReviewRowProps; /** The keys in use, as written in the strings or `keys`, for a hint line; a key that is off is left out. */ keys: Partial<Record<KeyboardReviewKey, string>>; }