Action Keys
betaGives a list of actions their single-key shortcuts. It shows nothing, and it ignores keys while someone is typing.
Preview
Point at the message, or Tab into it, then press R, E, or A.
Running ten minutes late for planning, start without me.
Keys are off. Last action: Nothing yet
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/action-keys
Use it for
- Single-key shortcuts for a focused or hovered row's actions, like R for Reply.
- Pass the same action objects you pass to HoverActions or BulkBar, so the key, the tooltip, and the button never drift apart.
- Pass the toolbar's ref as within. A key then presses its button, so it shows the same spinner, result, and Undo a click does.
- Turn it on for one row at a time with enabled. RowActions does this for you.
- When two are on at once, like a row under the pointer and another with focus, a key goes to the one turned on last that has it. It never acts on both.
- Put KeepTabInside last, right inside a popup of your own that keeps focus in, so a fast Tab cannot leave it.
Avoid it for
- Shortcuts with Cmd or Ctrl. It ignores keys with a modifier, so it never takes the browser's own.
- Keys inside a field. It ignores keys while someone types in an input, a textarea, or editable text.
- Turning it on for every row in a list at once. Only the row turned on last would answer, and no one could see which row that is.
Props
- actions
Required. The actions. Each with a shortcut gets its key.
ActionKey[]
- ActionKey
Type. shortcut is one key, matched in either case. A disabled action ignores its key. HoverAction and BulkBarAction fit it.
{ label: string; shortcut?: string; onClick?: () => void | Promise<unknown>; disabled?: boolean }
- enabled
Listens for keys. Turn it on for the focused or hovered row only. When two are on, the one turned on last answers a key.
boolean · default true
- within
The toolbar that holds the actions' buttons, like HoverActions' ref. A key presses the button whose data-shortcut matches. Without it, a key calls onClick.
RefObject<HTMLElement | null>
- isPageKey
Helper. Whether a key is free for page shortcuts: no one is typing, no modifier is held, and focus is not in an open dialog or menu.
(event: KeyboardEvent, within?: HTMLElement | null) => boolean
- KeepTabInside
Part, with no props. Put it last, right inside a popup that keeps focus in, not in a wrapper. Tab and Shift+Tab then wrap at the ends at once, so a fast Tab never lands on the page behind. Dialog, Alert Dialog, Sheet, Lightbox, Command Menu, and Notifications use it.
{}
| Prop | Type | Default | What it does |
|---|---|---|---|
| actions | ActionKey[] | - | Required. The actions. Each with a shortcut gets its key. |
| ActionKey | { label: string; shortcut?: string; onClick?: () => void | Promise<unknown>; disabled?: boolean } | - | Type. shortcut is one key, matched in either case. A disabled action ignores its key. HoverAction and BulkBarAction fit it. |
| enabled | boolean | true | Listens for keys. Turn it on for the focused or hovered row only. When two are on, the one turned on last answers a key. |
| within | RefObject<HTMLElement | null> | - | The toolbar that holds the actions' buttons, like HoverActions' ref. A key presses the button whose data-shortcut matches. Without it, a key calls onClick. |
| isPageKey | (event: KeyboardEvent, within?: HTMLElement | null) => boolean | - | Helper. Whether a key is free for page shortcuts: no one is typing, no modifier is held, and focus is not in an open dialog or menu. |
| KeepTabInside | {} | - | Part, with no props. Put it last, right inside a popup that keeps focus in, not in a wrapper. Tab and Shift+Tab then wrap at the ends at once, so a fast Tab never lands on the page behind. Dialog, Alert Dialog, Sheet, Lightbox, Command Menu, and Notifications use it. |
Accessibility
- Every key it listens for should be taught where people see the action, like HoverActions' tooltips, and set as aria-keyshortcuts on the button. ToolButton does both.
- It ignores keys while focus is in a text field, a textarea, a select, or editable text, so typing never runs an action.
- It ignores keys with Cmd, Ctrl, or Alt held, keys held down, and keys another handler already took.
- It ignores keys while focus is in an open dialog or menu that its toolbar is not part of.
- Single-key shortcuts can clash with speech input. Keep them on only while their row is hovered or focused, as WCAG 2.1.4 asks.
- KeepTabInside wraps Tab only past the last stop and Shift+Tab only before the first. In between, the browser moves focus as it always does. A field that takes Tab itself, and calls preventDefault, keeps it.
- Safari's Tab skips plain buttons and links, so it can pass the last stop KeepTabInside knows of. KeepTabInside then sends focus back in at once.
- A native radio group with a checked choice is one Tab stop, at that choice, as in the browser. Shift+Tab wraps back to it.
Examples
Keys for a focused row (needs @txbrendan/hover-actions)
import { Pencil, Reply } from "lucide-react";
import { useRef } from "react";
import { HoverActions, type HoverAction } from "@/components/ui/hover-actions";
function MessageActions({ focused, actions }: { focused: boolean; actions: HoverAction[] }) {
const bar = useRef<HTMLDivElement>(null);
return (
<>
<HoverActions ref={bar} visible={focused} actions={actions} />
<ActionKeys actions={actions} enabled={focused} within={bar} />
</>
);
}
<MessageActions focused actions={[{ icon: <Reply />, label: "Reply", shortcut: "R" }, { icon: <Pencil />, label: "Edit", shortcut: "E" }]} />