mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user