Skip to main content

Widget Hotkeys

Every interactive TradeScript widget registers a keyboard action inventory with a composition-scoped registry. The registry owns defaults, remaps, disables, persistence, and collision checks, and it is the same object the UI edits and your host code drives.

Hotkeys are scoped to a composition, not to the whole browser page. A trading terminal owns one scope automatically. When you compose widgets yourself, provide one registry to the group that must not collide.

The editor

One editor serves every widget. Open it from the keyboard button in a widget header, from a widget panel tab, from the chart toolbar's Hotkeys button, or from Settings → Keyboard shortcuts → Hotkeys.

The keyboard shortcut editor: search, filter pills, grouped actions, and platform-formatted chords

Within it:

  • Search matches action names, descriptions, group names, widget names, and chords, so emergency and ⌥⇧F both find Flatten position.
  • Filter pills narrow to Assigned, Custom, Conflicts, or Unassigned, each showing its own count. A filter with nothing to show is dimmed rather than hidden, so the row never reflows as the user edits.
  • Actions are grouped under the headings their catalog declares — Execution, Emergency, Quantity, Legs, Navigation — instead of one flat list.
  • Chords read as chords: ⌘⏎ on macOS, Ctrl+Enter elsewhere.
  • Recording is explicit: click a chord chip, press the combination, and it is claimed. Escape cancels, Backspace clears the binding.
  • Collisions name the other side and offer Take the chord, which releases the current owner and reassigns in one step.
  • Reset all returns every action in the scope to its shipped chord.

A chord claimed by two actions runs neither, so the header counts the collisions and the Conflicts filter lists exactly the rows to fix:

The Conflicts filter listing both sides of a collision, each naming the other action

Opened from a chart, the editor is capped at 75% of that chart's grid height, with a fixed header and footer and one scrollable body.

Scope

Launched from a widget, the editor opens on that widget alone and names it in the header. The pill beside it widens to Workspace — every action every mounted widget has registered in the same scope, which is also what you get when you mount the dialog without an instanceId.

The editor opened on one widget, with header pills switching between that widget and the whole workspace

The pair only appears when the scope holds more than one registered widget. Chart chrome registers separately from the chart — the data table, the workspace tab strip, and the session badge each own an action inventory — so a lone chart still offers the workspace view.

Defaults

High-frequency actions ship bound. DEFAULT_WIDGET_HOTKEY_BINDINGS is the single table behind them, and a test enforces that no chord in it is claimed twice or collides with a built-in chart shortcut.

WidgetActionDefault
Order ticketSubmit ordermod+enter
Order ticketConfirm previewmod+shift+enter
Order ticketLong / shortalt+shift+b / alt+shift+n
Level 2 ladderBuy / sell at marketalt+shift+arrowup / alt+shift+arrowdown
Level 2 ladderCancel all / flatten / reversealt+shift+x / alt+shift+f / alt+shift+z
Time & salesPause or resumealt+shift+p
Market depthShow or hide venuesalt+shift+v
WatchlistPrevious / next symbolalt+arrowup / alt+arrowdown
Option ticketSubmitmod+alt+enter

Enumerated long tails — order types, quick quantities, durations, expirations, strike counts, account pages, depth levels, widget panel tabs — ship deliberately unassigned so the table stays reviewable and free chords remain for your users to claim. Read the full table with DEFAULT_WIDGET_HOTKEY_BINDINGS, or one entry with defaultWidgetHotkeyBinding(widgetKind, actionId).

Charts keep their own 32 built-in shortcuts from listBuiltInActions() (mod+z, ⌥T for a trend line, ⌥A for an alert), remapped through chart.setShortcuts({ bindings }). Both sets share one collision scope.

Complete trading terminal

BrokerTradingTerminal creates the registry and passes it to the chart and every mounted panel. TradingTerminalApi.hotkeys is the same registry used by the visible setup buttons.

import {
BrokerTradingTerminal,
} from '@tradescript/pro/react'
import type { TradingTerminalApi } from '@tradescript/pro/sdk/trading'

let terminal: TradingTerminalApi | undefined

<BrokerTradingTerminal
sdk={sdk}
terminalId="primary-terminal"
adapter={adapter}
symbol="AAPL"
panels={{ marketDepth: true, timeAndSales: true, watchlist: true }}
watchlistAdapter={watchlistAdapter}
onTerminalReady={(api) => { terminal = api }}
/>

await terminal?.hotkeys.setBinding(
{ widgetKind: 'order-ticket', actionId: 'submit' },
'mod+enter',
)

The terminal tab renders the setup button. Child widgets hide their duplicate header button but still register and dispatch against their exact panel instance.

Compose widgets without collisions

Create one registry and pass it through TradeScriptProvider or HotkeyRegistryProvider:

import {
createHotkeyRegistry,
createLocalStorageHotkeyStorage,
} from '@tradescript/pro/sdk'
import {
MarketDepthWidget,
TimeAndSalesWidget,
TradeScriptProvider,
} from '@tradescript/pro/react'

const hotkeys = createHotkeyRegistry({
scopeId: 'main-workspace',
storage: createLocalStorageHotkeyStorage('my-app:hotkeys'),
onConflict: (conflicts) => console.warn('Hotkey conflict', conflicts),
})

<TradeScriptProvider sdk={sdk} hotkeys={hotkeys}>
<MarketDepthWidget
controller={marketData}
symbol={symbol}
hotkeyInstanceId="depth-left"
/>
<TimeAndSalesWidget
marketData={marketData}
symbol={symbol}
hotkeyInstanceId="tape-bottom"
/>
</TradeScriptProvider>

If two instances have the same widgetKind and actionId, they intentionally share one logical binding. Focus decides which instance runs. A different widget/action pair cannot claim the same chord inside that registry scope.

Open the editor yourself

HotkeySettingsDialog is the editor as a component. Mount it wherever your own chrome should open it — a preferences screen, a command palette result, a menu item — with or without a focused widget.

import { HotkeySettingsDialog } from '@tradescript/pro/react'

<HotkeySettingsDialog
open={open}
onOpenChange={setOpen}
registry={hotkeys}
/>

Omit instanceId for the whole composition, or pass one to open focused on a single widget. buildHotkeyEditorModel is the same pure read model the dialog renders, if you would rather draw your own surface over it.

Handle a programmatic assignment

setBinding returns a typed result. It never silently replaces another widget's binding.

const result = await hotkeys.setBinding(
{ widgetKind: 'time-and-sales', actionId: 'toggle-paused' },
'alt+t',
)

if (!result.ok) {
for (const conflict of result.conflicts) {
console.log(conflict.binding, conflict.occupiedBy)
}
}

Use null to disable an action, resetBinding(target) to return it to the widget default, and resetAllBindings() to remove every override in the scope.

Persist to your backend

Supply a HotkeyStorageAdapter when user settings live on your server:

const hotkeys = createHotkeyRegistry({
scopeId: `terminal:${user.id}`,
storage: {
load: async (scopeId) => api.getHotkeys(scopeId),
save: async (scopeId, bindings) => api.putHotkeys(scopeId, bindings),
},
})

await hotkeys.ready()

What the seam guarantees:

  • load runs once, at construction. Every mutation awaits that hydration first, so a binding the user changes during a slow load is never clobbered by the response.
  • save receives the complete override map after every accepted change — no diffing, no debounce. Coalesce writes in your adapter if your API prefers it.
  • The map is Record<string, string | null>, keyed by JSON.stringify([widgetKind, actionId]), where null means the user disabled the action. No instance ids and no widget panel arrangement coupling: the same record applies to any composition that registers those widget kinds.
  • ready() resolves once hydration finishes. The registry waits for persisted overrides before dispatching. In-memory registries dispatch synchronously, so browser default handling is prevented in the originating key event.

Omit storage and nothing persists — the scope lives for the session. createLocalStorageHotkeyStorage(prefix) is the browser-local adapter for hosts without a settings backend.

Watch for changes

subscribe reports every registry revision, so your own chrome can save, badge, or sync without polling:

const stop = hotkeys.subscribe((event) => {
if (event.type === 'binding-change') markShortcutsDirty(event.target)
})
event.typeFired when
registration-changeA widget mounted or unmounted its action inventory.
binding-changeAn assignment, reset, disable, or imported map was accepted.
action-runA dispatched chord ran its action.
hydratedPersisted overrides finished loading.

Every event carries a monotonic revision, plus instanceId or target when the change belongs to one of them. A bulk import emits a single binding-change with no target, not one per entry.

Share a setup between users

exportBindings() and applyBindings() are the two halves of a portable shortcut profile. The exported record is plain JSON — store it as a team preset, attach it to a user profile, or let one trader hand it to another.

// Publish
await api.putPreset('desk-scalping', hotkeys.exportBindings())

// Adopt
const result = await hotkeys.applyBindings(
await api.getPreset('desk-scalping'),
{ replace: true },
)

for (const entry of result.rejected) {
console.log(entry.target, entry.binding, entry.conflicts)
}

applyBindings judges the map as a finished state rather than entry by entry, which is what makes a real preset survive the trip: a profile that swaps two chords lands intact, where the same two setBinding calls would reject the first half of the swap. Entries that would still collide once the whole map is placed are dropped and returned in rejected, each naming the action already holding the chord — the rest of the preset applies. applied counts the entries that landed and ok is true only when none were dropped.

replace: true drops overrides absent from the incoming map, so the user ends up with exactly the preset. The default merges the map over what the user already has. Either way the scope persists once and emits one binding-change, and entries addressing widgets that are not currently mounted are kept for when they are.

Add host actions to a built-in widget

Every hotkey-enabled widget accepts hotkeyActions. The action runs inside the focused widget boundary and participates in the same collision checks. Declare defaultBinding to ship it bound; omit it to leave the chord to the user.

<BrokerOrderTicket
broker={broker}
symbol="AAPL"
hotkeyInstanceId="ticket-right"
hotkeyActions={[{
id: 'focus-risk-field',
label: 'Focus risk field',
group: 'Host actions',
defaultBinding: 'alt+shift+7',
run: () => riskInputRef.current?.focus(),
}]}
/>

Built-in catalogs include every finite command that can run without inventing input:

  • Order submission and preview actions; direction, order type, duration, and quantity choices.
  • Account pages and display modes.
  • Level 2 market and emergency actions.
  • Option-chain filters and option-ticket leg navigation.
  • Watchlist navigation, refresh, and export.
  • Depth display, tape controls, session details, and agent-console commands. Operations that require new text or a row-specific price stay attached to their existing input/row control; expose a host action that focuses or opens that UI rather than fabricating a value.

Which widgets participate

Charts, chart data tables, workspace tab strips, market depth, time and sales, watchlists, account panels, account summary bars, order tickets, order ticket launchers, account action dialogs, Level 2 ladders, option chains, option tickets, session badges, agent consoles, and every panel in a WidgetLayoutContainer.

Presentational controls — comparison pickers, the data window, news, instrument details, the period bar, the storage menu — expose no action inventory and render no keyboard button. TradeScriptMobileChart disables the entire layer: no dispatch, no toolbar button, no editor, because a phone has no chord to press. A keyboard-equipped shell turns it back on with features={{ shortcuts: true }}.

Custom widget panels

WidgetLayoutWidgetDefinition.hotkeys can be a static array or a function of the mounted panel context:

<WidgetLayoutContainer
widgets={[{
id: 'news',
title: 'News',
hotkeys: (context) => [{
id: 'refresh',
label: 'Refresh news',
run: () => refreshNews(context.widgetInstanceId),
}],
render: () => <NewsWidget />,
}]}
/>

WidgetLayoutContainer renders the keyboard button in each tab and registers the action against that panel's stable widgetInstanceId.

Keep each widget definition id stable when persisting the container. See Widget layout storage for the adapter and REST contract, and Widget panel arrangements for restore semantics.

Keyboard safety

Dispatch is focus-local and ignored inside inputs, textareas, editable elements, and textboxes. A widget rendered inside its own modal — an account action dialog, or the order ticket it nests — still dispatches, because the widget's own capture boundary already scopes the event.

Cmd and Ctrl normalize to mod, so a binding cannot evade collision checks by using the other platform spelling. Conflicting bindings fail closed: neither action runs until the user or host resolves the collision, and the editor surfaces the count in its header.

macOS composes Option+letter into another character — Option+C reports as ç — so chords are matched against the physical key whenever a modifier is held, and recorded the same way. ⌥C claims alt+c, not alt+ç. Unmodified chords still match the character actually typed, whatever the layout.

Next steps