# useKeyboardReview

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>
```

- Registry name: `use-keyboard-review` (`@candor/use-keyboard-review`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/use-keyboard-review

## Install

```bash
npx candor-ui add use-keyboard-review
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/use-keyboard-review`.
It brings in: locale, tokens.

## Example

```tsx
"use client"

import * as React from "react"
import { Check, Undo2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useKeyboardReview } from "@/lib/use-keyboard-review"
import { Button } from "@/components/ui/button"
import { Locked } from "@/components/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

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

## Strings (`keyboardReview`)

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

- `nextKey`: J
- `previousKey`: K
- `keepKey`: Y
- `dropKey`: N
- `undoKey`: U

## Types

### ReviewDecision

A person's decision on one reviewable row. A row with neither is undecided.

```ts
"keep" | "drop"
```

### KeyboardReviewKey

The keys `useKeyboardReview` listens for.

```ts
"next" | "previous" | "keep" | "drop" | "undo"
```

### KeyboardReviewOptions

What `useKeyboardReview` takes.

```ts
{
  /** 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.

```ts
{
  tabIndex: 0 | -1
  "data-review-row": ""
  "aria-keyshortcuts": string | undefined
  onFocus: () => void
  onKeyDown: (e: React.KeyboardEvent) => void
}
```

### KeyboardReview

What `useKeyboardReview` gives back.

```ts
{
  /** 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>>;
}
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
