chore(studio): migrate useKeyboardShortcuts to useShortcut (#45100)

## Summary
- Follow-up to #45099 — migrates the final legacy hotkey hook
(`useKeyboardShortcuts` in
`apps/studio/components/grid/components/common/Hooks.tsx`) to
`useShortcut`, backed by `SHORTCUT_DEFINITIONS`.
- Adds 4 registry entries (`TABLE_EDITOR_JUMP_FIRST_ROW` / `_LAST_ROW` /
`_FIRST_COL` / `_LAST_COL`), all `showInSettings: false` so they stay
non-configurable (same as today).
- Adds `ignoreInputs` to `ShortcutOptions` and threads it through
`useShortcut` → `useHotkeySequence`. Replaces the legacy
`whitelistNodes: ['INPUT', 'TEXTAREA', 'SELECT']` with the built-in
TanStack option, which also covers `contenteditable`.
- Drops the `navigator.appVersion` macOS/Windows detection in
`Shortcuts.tsx` (`Mod` resolves this automatically) and the manual
`event.stopPropagation()` calls (TanStack's default is `stopPropagation:
true`).
- Deletes `Hooks.tsx` — no remaining consumers.

Closes FE-3049.

## Why `ignoreInputs: true` on these entries
`Mod+Arrow*` is a Ctrl/Meta combo. TanStack's default for Ctrl/Meta
combos is `ignoreInputs: false`, which would fire the shortcut even when
focus is in a text input — not what we want. Setting `ignoreInputs:
true` in the registry preserves the legacy whitelist behavior.

## Test plan

All shortcuts should still fire with **Cmd** (macOS) / **Ctrl**
(Win/Linux). Test surface: **Table Editor** → open any table with rows
and multiple columns.

**Grid navigation (with a cell selected — click a cell first)**
- [x] `Cmd+ArrowUp` jumps selection to row 0 in the same column
- [x] `Cmd+ArrowDown` jumps selection to the last row in the same column
- [x] `Cmd+ArrowLeft` jumps selection to the first non-frozen column in
the same row
- [x] `Cmd+ArrowRight` jumps selection to the last column in the same
row (not the trailing "add column" cell)

**Whitelist regression (most important — the `ignoreInputs` check)**
- [x] Open the **Filter** popover on a table → put focus in the filter
value `<input>` → type with arrow keys / use `Cmd+ArrowLeft/Right` for
word-jump → cursor moves in the input, grid selection does **not** jump
- [x] Same for a `<textarea>` (e.g. row editor → a text column with a
textarea) — `Cmd+ArrowUp/Down` should not hijack focus away from the
textarea
- [x] Same for a `<select>` (e.g. row editor → enum / boolean column) —
arrow keys behave normally
- [x] Editing a cell value in-place (double-click a cell to enter edit
mode) — arrow keys navigate the text, don't jump the grid

**Platform check**
- [x] On macOS, the shortcuts fire with `Cmd` (not `Ctrl`)
- [x] On Windows/Linux, they fire with `Ctrl` (not `Cmd`) — `Mod`
resolution
- [x] None of the new entries appear in Account → Preferences → Keyboard
shortcuts (all `showInSettings: false`)

**Regression checks**
- [x] `pnpm --filter=studio typecheck` passes (verified locally)
- [x] Existing shortcuts from #45099 (`Cmd+K`, `Cmd+I`, `Cmd+S` in op
queue, results copy, etc.) still work unchanged

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added four keyboard shortcuts for table navigation: jump to first/last
row and first/last column using Mod+Arrow keys.
* Added a shortcut option to prevent triggers when focus is in input
fields.

* **Bug Fixes**
* Improved keyboard shortcut handling for more consistent behavior
across platforms.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-04-22 06:53:13 -06:00
1 parent fc0aae8b14
commit 3838e32d33
5 files changed
+82 -142

No files matched your search

@@ -1,85 +0,0 @@
import * as React from 'react'
function includes(array: string[], element: string) {
return array.indexOf(element) >= 0
}
/**
* Hook for listening on key events.
*
* @deprecated Use `useShortcut` from `state/shortcuts/useShortcut` instead.
* The new hook reads from a central registry, respects user preferences, and
* can surface shortcuts in the Cmd+P command menu.
*
* @param {Object|Map} keyMap Key names mapped to event handlers. If a key name exists, its
* default behavior will be suppressed.
* @param {Array} whitelistNodes If target element is in the whitelist nodes array, will not
* trigger shortcut event
* @param {Array} whitelistClasses If target class is in the whitelist classes array, will not
* trigger shortcut event
*/
export function useKeyboardShortcuts(
keyMap: { [id: KeyboardEvent['key']]: (event: KeyboardEvent) => void },
whitelistNodes: string[] = [],
whitelistClasses: string[] = []
) {
const [lastKeydown, setLastKeydown] = React.useState<string | null>()
const handleKeydown = (event: any) => {
if (
!keyMap ||
includes(whitelistNodes, event.target.nodeName) ||
includes(whitelistClasses, event.target.className)
) {
return
}
let keyPressed = getKeyPresses(event)
if (keyMap[keyPressed]) {
/**
* combined keymap will trigger action on KeyDown event
* while single keymap will trigger action on KeyUp event
*/
if (keyPressed.includes('+')) {
event.preventDefault()
keyMap[keyPressed](event)
setLastKeydown(null)
} else {
setLastKeydown(event.key)
event.preventDefault()
}
}
}
const handleKeyup = (event: any) => {
if (!keyMap) return
if (keyMap[event.key] && lastKeydown === event.key) {
event.preventDefault()
keyMap[event.key](event)
setLastKeydown(null)
}
}
function getKeyPresses(event: KeyboardEvent) {
return event.metaKey && event.shiftKey
? `Command+Shift+${event.key}`
: event.metaKey
? `Command+${event.key}`
: event.shiftKey && event.key === 'Enter'
? `Shift+${event.key}`
: event.ctrlKey && event.key
? `Control+${event.key}`
: event.key
}
React.useEffect(() => {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('keyup', handleKeyup)
return () => {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('keyup', handleKeyup)
}
})
}
@@ -1,8 +1,9 @@
import { RefObject, useMemo } from 'react'
import { RefObject } from 'react'
import type { DataGridHandle } from 'react-data-grid'
import { useKeyboardShortcuts } from './Hooks'
import { SupaRow } from '@/components/grid/types'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
type ShortcutsProps = {
@@ -13,62 +14,42 @@ type ShortcutsProps = {
export function Shortcuts({ gridRef, rows }: ShortcutsProps) {
const snap = useTableEditorTableStateSnapshot()
const metaKey = useMemo(() => {
function getClientOS() {
return navigator?.appVersion.indexOf('Win') !== -1
? 'windows'
: navigator?.appVersion.indexOf('Mac') !== -1
? 'macos'
: 'unknown'
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW, () => {
if (snap.selectedCellPosition) {
gridRef.current!.selectCell({
idx: snap.selectedCellPosition?.idx ?? 0,
rowIdx: 0,
})
} else {
gridRef.current!.scrollToCell({ rowIdx: 0 })
}
return getClientOS() === 'windows' ? 'Control' : 'Command'
}, [])
})
useKeyboardShortcuts(
{
[`${metaKey}+ArrowUp`]: (event) => {
event.stopPropagation()
if (snap.selectedCellPosition) {
const position = {
idx: snap.selectedCellPosition?.idx ?? 0,
rowIdx: 0,
}
gridRef.current!.selectCell(position)
} else {
gridRef.current!.scrollToCell({ rowIdx: Number(0) })
}
},
[`${metaKey}+ArrowDown`]: (event) => {
event.stopPropagation()
if (snap.selectedCellPosition) {
const position = {
idx: snap.selectedCellPosition?.idx ?? 0,
rowIdx: rows.length > 1 ? rows.length - 1 : 0,
}
gridRef.current!.selectCell(position)
} else {
gridRef.current!.scrollToCell({ rowIdx: Number(rows.length) })
}
},
[`${metaKey}+ArrowLeft`]: (event) => {
event.stopPropagation()
const fronzenColumns = snap.gridColumns.filter((x) => x.frozen)
const position = {
idx: fronzenColumns.length,
rowIdx: snap.selectedCellPosition?.rowIdx ?? 0,
}
gridRef.current!.selectCell(position)
},
[`${metaKey}+ArrowRight`]: (event) => {
event.stopPropagation()
gridRef.current?.selectCell({
idx: snap.gridColumns.length - 2, // -2 because we don't want to select the end extra col
rowIdx: snap.selectedCellPosition?.rowIdx ?? 0,
})
},
},
['INPUT', 'TEXTAREA', 'SELECT']
)
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW, () => {
if (snap.selectedCellPosition) {
gridRef.current!.selectCell({
idx: snap.selectedCellPosition?.idx ?? 0,
rowIdx: rows.length > 1 ? rows.length - 1 : 0,
})
} else {
gridRef.current!.scrollToCell({ rowIdx: rows.length })
}
})
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL, () => {
const frozenColumns = snap.gridColumns.filter((x) => x.frozen)
gridRef.current!.selectCell({
idx: frozenColumns.length,
rowIdx: snap.selectedCellPosition?.rowIdx ?? 0,
})
})
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL, () => {
gridRef.current?.selectCell({
idx: snap.gridColumns.length - 2,
rowIdx: snap.selectedCellPosition?.rowIdx ?? 0,
})
})
return null
}
+32
View File
@@ -16,6 +16,10 @@ export const SHORTCUT_IDS = {
RESULTS_COPY_JSON: 'results.copy-json',
RESULTS_COPY_CSV: 'results.copy-csv',
RESULTS_DOWNLOAD_CSV: 'results.download-csv',
TABLE_EDITOR_JUMP_FIRST_ROW: 'table-editor.jump-first-row',
TABLE_EDITOR_JUMP_LAST_ROW: 'table-editor.jump-last-row',
TABLE_EDITOR_JUMP_FIRST_COL: 'table-editor.jump-first-col',
TABLE_EDITOR_JUMP_LAST_COL: 'table-editor.jump-last-col',
DATA_TABLE_TOGGLE_FILTERS: 'data-table.toggle-filters',
DATA_TABLE_RESET_FILTERS: 'data-table.reset-filters',
DATA_TABLE_RESET_COLUMNS: 'data-table.reset-columns',
@@ -89,6 +93,34 @@ export const SHORTCUT_DEFINITIONS: Record<ShortcutId, ShortcutDefinition> = {
label: 'Download results as CSV',
sequence: ['Mod+Shift+D'],
},
[SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW]: {
id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW,
label: 'Jump to first row',
sequence: ['Mod+ArrowUp'],
showInSettings: false,
options: { ignoreInputs: true },
},
[SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW]: {
id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW,
label: 'Jump to last row',
sequence: ['Mod+ArrowDown'],
showInSettings: false,
options: { ignoreInputs: true },
},
[SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL]: {
id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL,
label: 'Jump to first column',
sequence: ['Mod+ArrowLeft'],
showInSettings: false,
options: { ignoreInputs: true },
},
[SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL]: {
id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL,
label: 'Jump to last column',
sequence: ['Mod+ArrowRight'],
showInSettings: false,
options: { ignoreInputs: true },
},
[SHORTCUT_IDS.AI_ASSISTANT_CANCEL_EDIT]: {
id: SHORTCUT_IDS.AI_ASSISTANT_CANCEL_EDIT,
label: 'Cancel AI Assistant edit',
+11
View File
@@ -34,6 +34,17 @@ export interface ShortcutOptions {
*/
timeout?: number
/**
* When `true`, suppresses the shortcut while focus is inside an input-like
* element (text input, textarea, select, contenteditable). Button-type
* inputs (type=button/submit/reset) are not ignored.
*
* Defaults to `undefined`, which falls through to TanStack's per-hotkey
* default: `true` for single keys and Shift/Alt combos, `false` for
* Ctrl/Meta/Mod shortcuts and Escape. Set explicitly to override.
*/
ignoreInputs?: boolean
/**
* When `true`, the shortcut also appears as an entry in the Cmd+P command
* menu (under the "Shortcuts" section) for as long as the hook is mounted.
+2 -1
View File
@@ -58,8 +58,9 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor
const callerEnabled = options?.enabled ?? def.options?.enabled ?? true
const enabled = globallyEnabled && callerEnabled
const timeout = options?.timeout ?? def.options?.timeout ?? undefined
const ignoreInputs = options?.ignoreInputs ?? def.options?.ignoreInputs
useHotkeySequence(def.sequence, callback, { enabled, timeout })
useHotkeySequence(def.sequence, callback, { enabled, timeout, ignoreInputs })
// Handle overrides for command menu
const enabledInCommandMenu = enabled && (options?.registerInCommandMenu ?? false)