component
Trainer action bars
The drill trainer's decision buttons, preflop and postflop, in the solver's action colors, with F, C and R hotkeys and raise sizes in bb or % pot.
The trainer's decision buttons. ActionBar takes a solver node's actions; pass the spot's history and stackBb to show raises as raise-to bb. PostflopActionBar takes a postflop drill's options. F, C and R pick fold, call and the first raise.
Preflop: BTN facing a UTG open, 100bb
Pick an action, or press F, C or R.
Postflop: facing a flop bet
Pick an action, or press F, C or R.
Installation
$ npx shadcn@latest add https://www.pokerstudy.ai/r/trainer-action-bars.jsonGenerative UI
A model can draw this with json-render. Install generative UI and spread its definitions into your catalog. The model reads the description and the schema below, and answers with a spec like the example.
ActionBar
The trainer's preflop decision buttons. Use when the player should choose an action at a solver node; bind `value` to state and emit "select" to react.
PostflopActionBar
The trainer's postflop decision buttons, from a postflop drill's options. Emits "select".
Props
Generated from the component source with react-docgen-typescript.
ActionBar
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | NodeAction[] | – | – |
| disabled * | boolean | – | – |
| history | string | – | Current spot history — enables bb raise-to conversion; without it, bb mode falls back to percentages. |
| onSelect * | (action: string) => void | – | – |
| stackBb | number | – | – |
PostflopActionBar
| Prop | Type | Default | Description |
|---|---|---|---|
| actions * | DrillActionOption[] | – | – |
| disabled * | boolean | – | – |
| onSelect * | (label: string) => void | – | – |
Source
"use client";
import { motion } from "motion/react";
import type { DrillActionOption } from "@/lib/trainer/postflop/types";
import { useTrainerSkin } from "./skin";
import { actionColor } from "./theme";
import { useFoldRaiseHotkeys } from "./use-action-hotkeys";
const KIND_ORDER: Record<DrillActionOption["kind"], number> = {
fold: 0,
check: 1,
call: 2,
raise: 3,
allin: 4,
};
/** The postflop decision buttons. Sizes on the labels are already in bb. */
export function PostflopActionBar({
actions,
disabled,
onSelect,
}: {
actions: DrillActionOption[];
disabled: boolean;
onSelect: (label: string) => void;
}) {
const skin = useTrainerSkin();
const sorted = [...actions].sort(
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.add - b.add,
);
const fold = sorted.find((a) => a.kind === "fold");
const call = sorted.find((a) => a.kind === "call" || a.kind === "check");
const raise = sorted.find((a) => a.kind === "raise");
useFoldRaiseHotkeys({
disabled,
onFold: fold ? () => onSelect(fold.label) : undefined,
onCall: call ? () => onSelect(call.label) : undefined,
onRaise: raise ? () => onSelect(raise.label) : undefined,
});
return (
<div className="flex w-full items-stretch gap-2">
{sorted.map((action) => {
const label = displayLabel(action);
const hotkey =
action.kind === "fold" ? "F" : action === call ? "C" : action === raise ? "R" : undefined;
const className =
"flex min-h-12 flex-1 items-center justify-center rounded-lg px-1.5 text-xs font-bold uppercase tracking-wide text-white transition-opacity hover:opacity-90 disabled:opacity-40 sm:min-h-14 sm:px-2 sm:text-sm";
const style = { backgroundColor: actionColor(action.kind, label) };
if (skin === "native") {
return (
<motion.button
key={action.label}
type="button"
disabled={disabled}
onClick={() => onSelect(action.label)}
style={style}
className={`${className} rounded-2xl`}
aria-keyshortcuts={hotkey?.toLowerCase()}
title={hotkey ? `${label} (${hotkey})` : label}
whileTap={disabled ? undefined : { scale: 0.94 }}
whileHover={disabled ? undefined : { scale: 1.02 }}
>
{label}
{hotkey ? (
<span aria-hidden className="ml-2 text-[10px] font-semibold tracking-normal text-white/55">
{hotkey}
</span>
) : null}
</motion.button>
);
}
return (
<button
key={action.label}
type="button"
disabled={disabled}
onClick={() => onSelect(action.label)}
style={style}
className={className}
aria-keyshortcuts={hotkey?.toLowerCase()}
title={hotkey ? `${label} (${hotkey})` : label}
>
{label}
{hotkey ? (
<span aria-hidden className="ml-2 text-[10px] font-semibold tracking-normal text-white/55">
{hotkey}
</span>
) : null}
</button>
);
})}
</div>
);
}
/** "Bet 5.8" → "BET 5.8", "AllIn 46.5" → "ALL-IN 46.5". */
function displayLabel(a: DrillActionOption): string {
const [head, size] = a.label.split(" ");
const word = head === "AllIn" ? "ALL-IN" : head.toUpperCase();
return size ? `${word} ${size}` : word;
}
import { useHotkeys, type Hotkey } from "@/lib/hotkeys";
/**
* F folds, C checks or calls, R takes the raise/bet. `sizes` maps extra keys
* (the Play bar's 2, 3, 4… size keys) to an immediate bet. Ignored while a field
* is focused or the bar is disabled, so typing in Settings / chat doesn't
* fire a decision; a dialog over the table mutes them too.
*/
export function useFoldRaiseHotkeys({
disabled,
onFold,
onCall,
onRaise,
sizes,
}: {
disabled: boolean;
onFold?: () => void;
onCall?: () => void;
onRaise?: () => void;
sizes?: Readonly<Record<string, () => void>>;
}) {
const group = "Actions";
const bindings: Hotkey[] = [];
if (onFold) bindings.push({ key: "f", label: "Fold", group, run: onFold });
if (onCall) bindings.push({ key: "c", label: "Check / call", group, run: onCall });
if (onRaise) bindings.push({ key: "r", label: "Bet / raise the picked size", group, run: onRaise });
for (const [key, run] of Object.entries(sizes ?? {})) {
bindings.push({ key, label: `Bet size ${key}`, group, run });
}
useHotkeys(bindings, !disabled);
}
"use client";
// One keyboard layer for the whole app. Every shortcut registers here with a
// label, so the `?` sheet can list what the current screen answers to, and a
// dialog (a `HotkeyLayer`) mutes the page underneath it: only bindings on the
// topmost open layer fire. Nothing fires while the user is typing.
import {
createContext,
createElement,
useContext,
useEffect,
useRef,
useSyncExternalStore,
type ReactNode,
} from "react";
export type Hotkey = {
/** `KeyboardEvent.key`; letters are matched case-insensitively ("f", "ArrowLeft", " ", "?"). */
key: string;
/** What it does, for the `?` sheet. */
label: string;
/** Heading it sits under in the `?` sheet. */
group?: string;
run: () => void;
/** Skip it this render (the button it mirrors is disabled). */
disabled?: boolean;
/** Fire again while the key is held. Arrows want this; actions don't. */
repeat?: boolean;
/**
* What a focused element blocks it on. "typing" (default): inputs and
* editors. "controls": also buttons and links, for keys like Space that a
* focused button already answers.
*/
blockedOn?: "typing" | "controls";
/** Fires on every layer (the `?` sheet itself). */
global?: boolean;
};
export type HotkeyEntry = Omit<Hotkey, "run" | "disabled"> & {
id: number;
layer: number;
run: () => void;
};
let nextId = 1;
let entries: HotkeyEntry[] = [];
const layerCounts = new Map<number, number>();
const listeners = new Set<() => void>();
let attached = false;
function emit() {
for (const l of listeners) l();
}
/** Letters match regardless of Shift or Caps Lock; named keys stay as they are. */
export function normalizeKey(key: string): string {
return key.length === 1 ? key.toLowerCase() : key;
}
export function topLayer(): number {
let top = 0;
for (const [layer, count] of layerCounts) if (count > 0 && layer > top) top = layer;
return top;
}
/** The bindings that would fire right now, one per key, in registration order. */
export function activeHotkeys(): HotkeyEntry[] {
const top = topLayer();
const seen = new Set<string>();
const out: HotkeyEntry[] = [];
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i];
if (!e.global && e.layer !== top) continue;
if (seen.has(e.key)) continue;
seen.add(e.key);
out.push(e);
}
return out.reverse();
}
export function isTyping(target: EventTarget | null): boolean {
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false;
// A focused bet slider is not typing: after a drag, R / 3 / 7 should still act.
if (target instanceof HTMLInputElement && target.type === "range") return false;
return Boolean(target.closest("input, textarea, select, [contenteditable=true], [contenteditable='']"));
}
function onControl(target: EventTarget | null): boolean {
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false;
return Boolean(target.closest("button, a, [role=button], [role=tab]"));
}
export function handleKeyDown(e: KeyboardEvent) {
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
const key = normalizeKey(e.key);
const top = topLayer();
for (let i = entries.length - 1; i >= 0; i--) {
const b = entries[i];
if (b.key !== key || (!b.global && b.layer !== top)) continue;
if (isTyping(e.target)) return;
if (b.blockedOn === "controls" && onControl(e.target)) return;
if (e.repeat && !b.repeat) {
e.preventDefault();
return;
}
e.preventDefault();
b.run();
return;
}
}
function attach() {
if (attached || typeof window === "undefined") return;
window.addEventListener("keydown", handleKeyDown);
attached = true;
}
export function registerHotkey(entry: Omit<HotkeyEntry, "id">): () => void {
attach();
const id = nextId++;
entries = [...entries, { ...entry, key: normalizeKey(entry.key), id }];
emit();
return () => {
entries = entries.filter((e) => e.id !== id);
emit();
};
}
export function pushLayer(layer: number): () => void {
layerCounts.set(layer, (layerCounts.get(layer) ?? 0) + 1);
emit();
return () => {
layerCounts.set(layer, (layerCounts.get(layer) ?? 1) - 1);
emit();
};
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
let snapshot: HotkeyEntry[] = [];
let snapshotOf: { entries: HotkeyEntry[]; top: number } | null = null;
function getSnapshot(): HotkeyEntry[] {
const top = topLayer();
if (!snapshotOf || snapshotOf.entries !== entries || snapshotOf.top !== top) {
snapshot = activeHotkeys();
snapshotOf = { entries, top };
}
return snapshot;
}
const EMPTY: HotkeyEntry[] = [];
/** Live list of what the current screen answers to (the `?` sheet). */
export function useActiveHotkeys(): HotkeyEntry[] {
return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY);
}
const LayerContext = createContext(0);
/**
* Wrap a dialog's contents: while it is mounted, hotkeys registered outside
* it stop firing, and the ones inside it take over.
*/
export function HotkeyLayer({ children }: { children: ReactNode }) {
const parent = useContext(LayerContext);
const layer = parent + 1;
useEffect(() => pushLayer(layer), [layer]);
return createElement(LayerContext.Provider, { value: layer }, children);
}
/**
* Bind keys for as long as the component is mounted. Handlers can change
* every render; a binding re-registers only when its key, label or
* disabled state changes.
*/
export function useHotkeys(bindings: readonly Hotkey[], enabled = true) {
const layer = useContext(LayerContext);
const latest = useRef(bindings);
useEffect(() => {
latest.current = bindings;
});
const live = enabled ? bindings.filter((b) => !b.disabled) : [];
const signature = live
.map((b) => [b.key, b.label, b.group ?? "", b.repeat ? 1 : 0, b.blockedOn ?? "", b.global ? 1 : 0].join("\u0001"))
.join("\u0002");
useEffect(() => {
if (!signature) return;
const offs = signature.split("\u0002").map((sig) => {
const [key, label, group, repeat, blockedOn, global] = sig.split("\u0001");
return registerHotkey({
key,
label,
group: group || undefined,
repeat: repeat === "1",
blockedOn: (blockedOn || undefined) as Hotkey["blockedOn"],
global: global === "1",
layer,
run: () => latest.current.find((b) => normalizeKey(b.key) === normalizeKey(key) && !b.disabled)?.run(),
});
});
return () => offs.forEach((off) => off());
}, [signature, layer]);
}
/** How a key reads on a keycap. */
export function keyCap(key: string): string {
switch (key) {
case "ArrowLeft":
return "←";
case "ArrowRight":
return "→";
case "ArrowUp":
return "↑";
case "ArrowDown":
return "↓";
case " ":
return "Space";
case "Escape":
return "Esc";
default:
return key.length === 1 ? key.toUpperCase() : key;
}
}
/** Test hook: drop every binding and layer. */
export function resetHotkeysForTest() {
entries = [];
layerCounts.clear();
emit();
}