feat(studio): add keyboard shortcuts to the table editor (#45178)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — a set of new keyboard shortcuts for the table editor, along
with infrastructure to register, gate, and surface them.

## What is the current behavior?

Clicking into the grid "traps" the keyboard: Escape doesn't pop out,
there are no shortcuts for row selection / deletion / navigation, and
the search-tables input grabs focus on page load.

## What is the new behavior?

### New shortcuts (all scoped to the table editor)

| Keybind | Action | Surface |
|---|---|---|
| `Esc` | Exit grid selection — clears the highlighted cell and drops
focus back to the page | hotkey |
| `↑` / `↓` | Start grid navigation from the first cell when no cell is
selected | hotkey |
| `Shift+Space` | Toggle selection on the current row | hotkey +
checkbox tooltip |
| `Mod+A` | Toggle selection on all displayed rows (matches Excel) |
hotkey + header-checkbox tooltip + Cmd+K |
| `Mod+Shift+A` | Toggle selection on all rows in the table | hotkey +
"Select all rows in table" button tooltip + Cmd+K |
| `Mod+Backspace` | Delete selected rows | hotkey + delete-button
tooltip + Cmd+K |

### Infrastructure

- **Split registry** — table-editor shortcuts moved to
`state/shortcuts/registry/table-editor.ts`, spread into `SHORTCUT_IDS`.
Makes it easy to scope a runtime check to a specific surface.
- **`eventMatchesAnyShortcut`** (`state/shortcuts/matchEvent.ts`) —
queries the hotkey library's live `SequenceManager` so gated shortcuts
(`enabled: false`) are correctly excluded. Covered by
`matchEvent.test.ts`.
- **`handleCellKeyDown`** now calls `event.preventGridDefault()`
whenever the keystroke matches an active table-editor shortcut, so rdg's
"start editing on key press" default doesn't compete with shortcut
actions (e.g. typing `Shift+X` no longer opens edit mode with `X` as
input).
- **`<Shortcut>` / `<ShortcutTooltip>`** used on the header checkbox,
the per-row checkbox, the "Select all rows in table" button, and the
delete button — keybinds show up on hover (Linear-style) so users can
discover them without reading docs.
- **CSS** — `.rdg:not(:focus-within) .rdg-cell[aria-selected='true']`
drops the selected-cell outline whenever focus leaves the grid,
reinforcing the "you're out" feedback after `Esc`.
- **`useShortcut`** wraps the Cmd+K-registered action to close the
command menu after firing (previously menu stayed open after selecting
an action).
- **Search-tables input** no longer auto-focuses on load, so arrow
shortcuts work immediately without clicking out first.

## Additional context

Linear: FE-3057

### Test plan

- [x] Open any table → `↓` selects the first cell; subsequent arrows
navigate rows
- [x] `Esc` drops focus out of the grid and re-enables `↓` to re-enter
- [x] Click a cell → `Shift+Space` toggles that row's selection
(checkbox)
- [x] `Mod+A` toggles all displayed rows
- [x] With pagination + some rows selected → `Mod+Shift+A` toggles
"Select all rows in table"
- [x] With rows selected → `Mod+Backspace` deletes them (existing
confirmation flow)
- [x] Hover the header checkbox / per-row checkbox / delete button →
keybind tooltip after ~500ms
- [x] Cmd+K with selection → the relevant action shows up; selecting it
closes the palette and runs

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

* **New Features**
* Added table editor keyboard shortcuts for navigation, row selection,
and cell actions, with command-menu integration and visible shortcut
tooltips.

* **Improvements**
* Better keyboard handling in grid cells allowing external shortcuts to
override default behavior.
* Select-all/deselect-all toggle and improved select-row UX;
selected-cell styling no longer shows when grid loses focus.
  * Command menu now reliably closes before executing shortcut actions.
* Removed autofocus on the table editor search input for consistent
focus behavior.

* **Tests**
* Added unit tests covering shortcut matching and command-menu shortcut
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-04-28 08:20:04 -06:00
1 parent b975941c59
commit 42b431a270
15 files changed
+509 -109

No files matched your search

@@ -3,16 +3,24 @@ import { compact } from 'lodash'
import { useSearchParams } from 'next/navigation'
import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useEffect, useMemo } from 'react'
import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid'
import {
CalculatedColumn,
CellKeyboardEvent,
CellKeyDownArgs,
RowsChangeData,
} from 'react-data-grid'
import { copyToClipboard } from 'ui'
import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
import { STORAGE_KEY_PREFIX } from './constants'
import type { Sort, SupaColumn, SupaTable } from './types'
import type { Sort, SupaColumn, SupaRow, SupaTable } from './types'
import { formatClipboardValue } from './utils/common'
import { isBoolColumn } from './utils/types'
import type { Filter, SavedState } from '@/components/grid/types'
import { Entity, isTableLike } from '@/data/table-editor/table-editor-types'
import { BASE_PATH } from '@/lib/constants'
import { eventMatchesAnyShortcut } from '@/state/shortcuts/matchEvent'
import { tableEditorRegistry } from '@/state/shortcuts/registry/table-editor'
export function formatSortURLParams(tableName: string, sort?: string[]): Sort[] {
if (Array.isArray(sort)) {
@@ -264,18 +272,51 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({
}, [urlParams, table, projectRef])
}
export const handleCopyCell = (
{
mode,
column,
row,
}: { mode: 'SELECT' | 'EDIT'; column: CalculatedColumn<any, unknown>; row: any },
event: CellKeyboardEvent
export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
args: CellKeyDownArgs<TRow, unknown>,
event: CellKeyboardEvent,
context?: {
rows: TRow[]
columns: SupaColumn[]
onRowsChange: (rows: TRow[], data: RowsChangeData<TRow, unknown>) => void
}
) => {
if (mode === 'SELECT' && event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) {
const colKey = column.key
const cellValue = row[colKey] ?? ''
const { mode, column, row, rowIdx } = args
if (mode !== 'SELECT') return
if (event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) {
const cellValue = row[column.key] ?? ''
const value = formatClipboardValue(cellValue)
copyToClipboard(value)
}
// Let registered shortcuts win over rdg's "type a key to enter edit mode" default.
if (eventMatchesAnyShortcut(event.nativeEvent, tableEditorRegistry)) {
event.preventGridDefault()
return
}
// Toggle boolean cells with T/F when no modifier keys are pressed.
if (context === undefined) return
const key = event.key.toLowerCase()
if (event.altKey || event.ctrlKey || event.metaKey || (key !== 't' && key !== 'f')) return
const supaColumn = context.columns.find((c) => c.name === column.key)
if (
supaColumn === undefined ||
!isBoolColumn(supaColumn.dataType) ||
column.renderEditCell == null
) {
return
}
event.preventDefault()
event.preventGridDefault()
const nextValue = key === 't'
if (row[column.key] === nextValue) return
const updatedRows = [...context.rows]
updatedRows[rowIdx] = { ...row, [column.key]: nextValue }
context.onRowsChange(updatedRows, { indexes: [rowIdx], column })
}
@@ -1,10 +1,13 @@
import { RefObject } from 'react'
import { RefObject, useContext } from 'react'
import type { DataGridHandle } from 'react-data-grid'
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'
import {
TableEditorTableStateContext,
useTableEditorTableStateSnapshot,
} from '@/state/table-editor-table'
type ShortcutsProps = {
gridRef: RefObject<DataGridHandle>
@@ -13,6 +16,16 @@ type ShortcutsProps = {
export function Shortcuts({ gridRef, rows }: ShortcutsProps) {
const snap = useTableEditorTableStateSnapshot()
const state = useContext(TableEditorTableStateContext)
const canStartNavigation = !snap.selectedCellPosition && rows.length > 0
const startGridNavigation = () => {
const frozenColumns = snap.gridColumns.filter((x) => x.frozen)
gridRef.current?.selectCell({
idx: frozenColumns.length,
rowIdx: 0,
})
}
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW, () => {
if (snap.selectedCellPosition) {
@@ -51,5 +64,44 @@ export function Shortcuts({ gridRef, rows }: ShortcutsProps) {
})
})
useShortcut(
SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ROW_SELECTION,
() => {
const rowIdx = state.selectedCellPosition?.rowIdx
if (rowIdx === undefined) return
const row = rows[rowIdx]
if (!row) return
const next = new Set(state.selectedRows)
if (next.has(row.idx)) next.delete(row.idx)
else next.add(row.idx)
state.setSelectedRows(next)
},
{
enabled: !!snap.selectedCellPosition,
}
)
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_DOWN, startGridNavigation, {
enabled: canStartNavigation,
})
useShortcut(SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_UP, startGridNavigation, {
enabled: canStartNavigation,
})
useShortcut(
SHORTCUT_IDS.TABLE_EDITOR_EXIT_SELECTION,
() => {
snap.setSelectedCellPosition(null)
;(document.activeElement as HTMLElement | null)?.blur()
},
{
enabled: !!snap.selectedCellPosition,
}
)
return null
}
@@ -14,7 +14,7 @@ import { useOnRowsChange } from './Grid.utils'
import { GridError } from './GridError'
import { RowContextMenuProvider, RowRenderer } from './RowRenderer'
import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
import { handleCopyCell } from '@/components/grid/SupabaseGrid.utils'
import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils'
import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils'
import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
@@ -362,7 +362,13 @@ export const Grid = memo(
onRowDoubleClick(props.row, { name: props.column.name })
}
}}
onCellKeyDown={handleCopyCell}
onCellKeyDown={(args, event) =>
handleCellKeyDown(args, event, {
rows: rows ?? [],
columns: snap.table.columns,
onRowsChange,
})
}
/>
</RowContextMenuProvider>
{/* The DragOverlay is necessary to avoid styling issues while dragging a column */}
@@ -12,6 +12,9 @@ import {
import { SELECT_COLUMN_KEY } from '../../constants'
import type { SupaRow } from '../../types'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { Shortcut } from '@/components/ui/Shortcut'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
@@ -125,17 +128,23 @@ function SelectCellFormatter({
return (
<div className="sb-grid-select-cell__formatter">
<input
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
tabIndex={tabIndex}
type="checkbox"
className="rdg-row__select-column__select-action"
disabled={disabled}
checked={value}
onChange={handleChange}
onClick={onClick}
/>
<ShortcutTooltip
shortcutId={SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ROW_SELECTION}
delayDuration={1000}
side="left"
>
<input
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
tabIndex={tabIndex}
type="checkbox"
className="rdg-row__select-column__select-action"
disabled={disabled}
checked={value}
onChange={handleChange}
onClick={onClick}
/>
</ShortcutTooltip>
{row && (
<ButtonTooltip
type="text"
@@ -187,18 +196,26 @@ function SelectCellHeader({
return (
<div className="sb-grid-select-cell__header">
<input
ref={inputRef}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
tabIndex={tabIndex}
type="checkbox"
className="sb-grid-select-cell__header__input"
disabled={disabled}
checked={value}
onChange={handleChange}
onClick={onClick}
/>
<Shortcut
id={SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ALL_ROW_SELECTION}
onTrigger={() => onChange(!value, false)}
options={{
registerInCommandMenu: true,
}}
>
<input
ref={inputRef}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
tabIndex={tabIndex}
type="checkbox"
className="sb-grid-select-cell__header__input"
disabled={disabled}
checked={value}
onChange={handleChange}
onClick={onClick}
/>
</Shortcut>
</div>
)
}
@@ -30,6 +30,7 @@ import {
useExportAllRowsAsSql,
} from '@/components/layouts/TableEditorLayout/ExportAllRows'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { Shortcut } from '@/components/ui/Shortcut'
import { useTableRowsCountQuery } from '@/data/table-rows/table-rows-count-query'
import { useTableRowsQuery } from '@/data/table-rows/table-rows-query'
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
@@ -41,6 +42,7 @@ import {
useRoleImpersonationStateSnapshot,
useSubscribeToImpersonatedRole,
} from '@/state/role-impersonation-state'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
@@ -262,6 +264,14 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
snap.setSelectedRows(new Set(allRows.map((row) => row.idx)), true)
}
const onToggleSelectAllInTable = () => {
if (snap.allRowsSelected) {
snap.setSelectedRows(new Set(), false)
} else {
onSelectAllRows()
}
}
const onRowsDelete = () => {
const rowIdxs = Array.from(snap.selectedRows) as number[]
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
@@ -386,28 +396,37 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
<>
<div className="flex items-center gap-x-2">
{snap.editable && (
<ButtonTooltip
type="default"
size="tiny"
icon={<Trash />}
onClick={onRowsDelete}
disabled={snap.allRowsSelected && isImpersonatingRole}
tooltip={{
content: {
side: 'bottom',
text:
snap.allRowsSelected && isImpersonatingRole
? 'Table truncation is not supported when impersonating a role'
: undefined,
},
<Shortcut
id={SHORTCUT_IDS.TABLE_EDITOR_DELETE_SELECTED_ROWS}
onTrigger={onRowsDelete}
options={{
registerInCommandMenu: true,
enabled: !(snap.allRowsSelected && isImpersonatingRole),
}}
>
{snap.allRowsSelected
? `Delete all rows in table`
: snap.selectedRows.size > 1
? `Delete ${snap.selectedRows.size} rows`
: `Delete ${snap.selectedRows.size} row`}
</ButtonTooltip>
<ButtonTooltip
type="default"
size="tiny"
icon={<Trash />}
onClick={onRowsDelete}
disabled={snap.allRowsSelected && isImpersonatingRole}
tooltip={{
content: {
side: 'bottom',
text:
snap.allRowsSelected && isImpersonatingRole
? 'Table truncation is not supported when impersonating a role'
: undefined,
},
}}
>
{snap.allRowsSelected
? `Delete all rows in table`
: snap.selectedRows.size > 1
? `Delete ${snap.selectedRows.size} rows`
: `Delete ${snap.selectedRows.size} row`}
</ButtonTooltip>
</Shortcut>
)}
{!snap.allRowsSelected ? (
@@ -473,14 +492,20 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
</DropdownMenuContent>
</DropdownMenu>
{!snap.allRowsSelected && totalRows > allRows.length && (
{snap.selectedRows.size > 0 && totalRows > allRows.length && (
<>
<div className="h-6 ml-0.5">
<Separator orientation="vertical" />
</div>
<Button type="text" onClick={() => onSelectAllRows()}>
Select all rows in table
</Button>
<Shortcut
id={SHORTCUT_IDS.TABLE_EDITOR_SELECT_ALL_IN_TABLE}
onTrigger={onToggleSelectAllInTable}
options={{ registerInCommandMenu: true }}
>
<Button type="text" onClick={onToggleSelectAllInTable}>
{snap.allRowsSelected ? 'Deselect all rows in table' : 'Select all rows in table'}
</Button>
</Shortcut>
</>
)}
</div>
@@ -12,7 +12,7 @@ import {
import { CellDetailPanel } from './CellDetailPanel'
import { formatCellValue, formatClipboardValue } from './Results.utils'
import { handleCopyCell } from '@/components/grid/SupabaseGrid.utils'
import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils'
export const Results = ({ rows }: { rows: readonly any[] }) => {
const [expandCell, setExpandCell] = useState(false)
@@ -137,7 +137,7 @@ export const Results = ({ rows }: { rows: readonly any[] }) => {
className="grow min-h-0 border-t-0"
rowClass={() => '[&>.rdg-cell]:items-center'}
onSelectedCellChange={setCellPosition}
onCellKeyDown={handleCopyCell}
onCellKeyDown={handleCellKeyDown}
/>
<CellDetailPanel
column={cellPosition?.column.name ?? ''}
@@ -1,7 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { useBreakpoint } from 'common/hooks/useBreakpoint'
import { Filter, Plus } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
@@ -48,7 +47,6 @@ export const TableEditorMenu = () => {
const id = _id ? Number(_id) : undefined
const snap = useTableEditorStateSnapshot()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const isMobile = useBreakpoint()
const [searchText, setSearchText] = useState<string>('')
const [tableToExport, setTableToExport] = useState<SupaTable>()
@@ -201,7 +199,6 @@ export const TableEditorMenu = () => {
<div className="grow min-h-0 flex flex-col gap-2 pb-4">
<InnerSideBarFilters className="mx-2">
<InnerSideBarFilterSearchInput
autoFocus={!isMobile}
name="search-tables"
value={searchText}
placeholder="Search tables..."
@@ -0,0 +1,121 @@
import { getSequenceManager } from '@tanstack/react-hotkeys'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventMatchesAnyShortcut } from './matchEvent'
import type { RegistryDefinations } from './types'
vi.mock('@tanstack/react-hotkeys', async () => {
const actual =
await vi.importActual<typeof import('@tanstack/react-hotkeys')>('@tanstack/react-hotkeys')
return {
...actual,
getSequenceManager: vi.fn(),
}
})
type FakeRegistration = {
options: { enabled?: boolean }
sequence: string[]
}
/** Swap the SequenceManager's registrations for the ones we care about. */
function withRegistrations(registrations: FakeRegistration[]) {
const state = new Map(registrations.map((r, i) => [String(i), r]))
vi.mocked(getSequenceManager).mockReturnValue({
registrations: { state },
} as unknown as ReturnType<typeof getSequenceManager>)
}
const shiftX = new KeyboardEvent('keydown', {
key: 'X',
code: 'KeyX',
shiftKey: true,
})
const arrowDown = new KeyboardEvent('keydown', {
key: 'ArrowDown',
code: 'ArrowDown',
})
const g = new KeyboardEvent('keydown', {
key: 'g',
code: 'KeyG',
})
const shiftK = new KeyboardEvent('keydown', {
key: 'K',
code: 'KeyK',
shiftKey: true,
})
const registry: RegistryDefinations<string> = {
'table.shift-x': {
id: 'table.shift-x',
label: 'Toggle row',
sequence: ['Shift+X'],
},
'table.arrow-down': {
id: 'table.arrow-down',
label: 'Start navigation (down)',
sequence: ['ArrowDown'],
},
'table.chord': {
id: 'table.chord',
label: 'Chord',
sequence: ['G', 'T'],
},
}
describe('eventMatchesAnyShortcut', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns false when there are no active registrations', () => {
withRegistrations([])
expect(eventMatchesAnyShortcut(shiftX, registry)).toBe(false)
})
it('returns true when an active, enabled shortcut in scope matches', () => {
withRegistrations([{ options: {}, sequence: ['Shift+X'] }])
expect(eventMatchesAnyShortcut(shiftX, registry)).toBe(true)
})
it('returns false when the matching shortcut is disabled', () => {
withRegistrations([{ options: { enabled: false }, sequence: ['ArrowDown'] }])
expect(eventMatchesAnyShortcut(arrowDown, registry)).toBe(false)
})
it('treats enabled: undefined and enabled: true as enabled', () => {
withRegistrations([
{ options: {}, sequence: ['Shift+X'] },
{ options: { enabled: true }, sequence: ['ArrowDown'] },
])
expect(eventMatchesAnyShortcut(arrowDown, registry)).toBe(true)
})
it('returns false when the matching registration is not in the target registry', () => {
// 'Shift+K' is registered and matches the event, but our scoped registry
// doesn't include it — so we should not claim a match.
withRegistrations([{ options: {}, sequence: ['Shift+K'] }])
expect(eventMatchesAnyShortcut(shiftK, registry)).toBe(false)
})
it('matches any individual step of a chord sequence', () => {
withRegistrations([{ options: {}, sequence: ['G', 'T'] }])
expect(eventMatchesAnyShortcut(g, registry)).toBe(true)
})
it('returns true if any enabled duplicate matches, even when a disabled one is also present', () => {
withRegistrations([
{ options: { enabled: false }, sequence: ['Shift+X'] },
{ options: {}, sequence: ['Shift+X'] },
])
expect(eventMatchesAnyShortcut(shiftX, registry)).toBe(true)
})
it('returns false when no active sequence matches the event', () => {
withRegistrations([{ options: {}, sequence: ['Shift+X'] }])
expect(eventMatchesAnyShortcut(arrowDown, registry)).toBe(false)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { getSequenceManager, matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { SHORTCUT_DEFINITIONS } from './registry'
import type { RegistryDefinations } from './types'
/**
* Returns true if the given keyboard event matches a shortcut that is both:
*
* 1. **In the target registry** (defaults to every known shortcut, but callers
* can pass a subset like `tableEditorRegistry` to scope the check)
* 2. **Currently active and enabled** — i.e. a `useShortcut` is mounted for it
* AND its `enabled` option is not `false`
*
* Chord sequences (e.g. `['G', 'T']`) match on any individual step, so
* pressing `G` counts as a match while the chord is in flight.
*
* Respecting the live `enabled` state matters: if a shortcut is registered but
* gated off (e.g. `enabled: !!snap.selectedCellPosition`), we must NOT suppress
* the default behavior on its behalf, because the shortcut won't actually fire.
*/
export function eventMatchesAnyShortcut(
event: KeyboardEvent,
registry: RegistryDefinations<string> = SHORTCUT_DEFINITIONS
): boolean {
const scopedSteps = new Set(Object.values(registry).flatMap((def) => def.sequence))
const activeRegistrations = getSequenceManager().registrations.state.values()
for (const view of activeRegistrations) {
if (view.options.enabled === false) continue
const matches = view.sequence.some(
(step) => scopedSteps.has(step) && matchesKeyboardEvent(event, step)
)
if (matches) return true
}
return false
}
+7 -32
View File
@@ -1,3 +1,4 @@
import { TABLE_EDITOR_SHORTCUT_IDS, tableEditorRegistry } from './registry/table-editor'
import { ShortcutDefinition } from './types'
/**
@@ -16,10 +17,6 @@ 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',
@@ -49,6 +46,9 @@ export const SHORTCUT_IDS = {
NAV_ORG_BILLING: 'nav.org-billing',
NAV_ORG_SETTINGS: 'nav.org-settings',
SHORTCUTS_OPEN_REFERENCE: 'shortcuts.open-reference',
// Table editor shortcuts
...TABLE_EDITOR_SHORTCUT_IDS,
} as const
/**
@@ -113,34 +113,6 @@ 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',
@@ -322,4 +294,7 @@ export const SHORTCUT_DEFINITIONS: Record<ShortcutId, ShortcutDefinition> = {
showInSettings: false,
options: { ignoreInputs: true },
},
// Table editor shortcut registration
...tableEditorRegistry,
}
@@ -0,0 +1,98 @@
import { RegistryDefinations } from '../types'
export const TABLE_EDITOR_SHORTCUT_IDS = {
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',
TABLE_EDITOR_TOGGLE_ROW_SELECTION: 'table-editor.toggle-row-selection',
TABLE_EDITOR_TOGGLE_ALL_ROW_SELECTION: 'table-editor.toggle-all-row-selection',
TABLE_EDITOR_SELECT_ALL_IN_TABLE: 'table-editor.select-all-in-table',
TABLE_EDITOR_DELETE_SELECTED_ROWS: 'table-editor.delete-selected-rows',
TABLE_EDITOR_START_NAVIGATION_DOWN: 'table-editor.start-navigation-down',
TABLE_EDITOR_START_NAVIGATION_UP: 'table-editor.start-navigation-up',
TABLE_EDITOR_EXIT_SELECTION: 'table-editor.exit-selection',
}
export type TableEditorShortcutId =
(typeof TABLE_EDITOR_SHORTCUT_IDS)[keyof typeof TABLE_EDITOR_SHORTCUT_IDS]
export const tableEditorRegistry: RegistryDefinations<TableEditorShortcutId> = {
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW,
label: 'Jump to first row',
sequence: ['Mod+ArrowUp'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW,
label: 'Jump to last row',
sequence: ['Mod+ArrowDown'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL,
label: 'Jump to first column',
sequence: ['Mod+ArrowLeft'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL,
label: 'Jump to last column',
sequence: ['Mod+ArrowRight'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ROW_SELECTION]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ROW_SELECTION,
label: 'Toggle selection on current row',
sequence: ['Shift+Space'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ALL_ROW_SELECTION]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ALL_ROW_SELECTION,
label: 'Toggle selection on all displayed rows',
sequence: ['Mod+A'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_SELECT_ALL_IN_TABLE]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_SELECT_ALL_IN_TABLE,
label: 'Toggle selection on all rows in table',
sequence: ['Mod+Shift+A'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_DELETE_SELECTED_ROWS]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_DELETE_SELECTED_ROWS,
label: 'Delete selected rows',
sequence: ['Mod+Backspace'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_DOWN]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_DOWN,
label: 'Start grid navigation (down)',
sequence: ['ArrowDown'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_UP]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_UP,
label: 'Start grid navigation (up)',
sequence: ['ArrowUp'],
showInSettings: false,
options: { ignoreInputs: true },
},
[TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_EXIT_SELECTION]: {
id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_EXIT_SELECTION,
label: 'Exit grid selection',
sequence: ['Escape'],
showInSettings: false,
options: { ignoreInputs: true },
},
}
+2
View File
@@ -99,3 +99,5 @@ export interface ShortcutDefinition {
*/
showInSettings?: boolean
}
export type RegistryDefinations<T extends string> = Record<T, ShortcutDefinition>
@@ -4,13 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from './registry'
import { useShortcut } from './useShortcut'
const { mockUseHotkeySequence, mockUseRegisterCommands, mockUseIsShortcutEnabled } = vi.hoisted(
() => ({
mockUseHotkeySequence: vi.fn(),
mockUseRegisterCommands: vi.fn(),
mockUseIsShortcutEnabled: vi.fn(),
})
)
const {
mockUseHotkeySequence,
mockUseRegisterCommands,
mockUseIsShortcutEnabled,
mockSetCommandMenuOpen,
} = vi.hoisted(() => ({
mockUseHotkeySequence: vi.fn(),
mockUseRegisterCommands: vi.fn(),
mockUseIsShortcutEnabled: vi.fn(),
mockSetCommandMenuOpen: vi.fn(),
}))
vi.mock('@tanstack/react-hotkeys', () => ({
useHotkeySequence: mockUseHotkeySequence,
@@ -18,6 +22,7 @@ vi.mock('@tanstack/react-hotkeys', () => ({
vi.mock('ui-patterns/CommandMenu', () => ({
useRegisterCommands: mockUseRegisterCommands,
useSetCommandMenuOpen: () => mockSetCommandMenuOpen,
}))
vi.mock('./useIsShortcutEnabled', () => ({
@@ -204,6 +209,19 @@ describe('useShortcut', () => {
expect(cb2).toHaveBeenCalledTimes(1)
})
it('command action closes the command menu when fired', () => {
const cb = vi.fn()
renderHook(() =>
useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, cb, { registerInCommandMenu: true })
)
const action = mockUseRegisterCommands.mock.calls[0][1][0].action
action()
expect(mockSetCommandMenuOpen).toHaveBeenCalledWith(false)
expect(cb).toHaveBeenCalledTimes(1)
})
it('command action identity is stable across renders', () => {
const { rerender } = renderHook(
({ cb }: { cb: () => void }) =>
+7 -2
View File
@@ -1,7 +1,7 @@
import { useHotkeySequence } from '@tanstack/react-hotkeys'
import { Fragment, useCallback } from 'react'
import { KeyboardShortcut } from 'ui'
import { useRegisterCommands } from 'ui-patterns/CommandMenu'
import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry'
import type { ShortcutOptions } from './types'
@@ -75,7 +75,11 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor
const enabledInCommandMenu = enabled && (options?.registerInCommandMenu ?? false)
const depsInCommandMenu = [enabled, def.label]
const callbackRef = useLatest(callback)
const stableAction = useCallback(() => callbackRef.current(), [callbackRef])
const setCommandMenuOpen = useSetCommandMenuOpen()
const stableAction = useCallback(() => {
setCommandMenuOpen(false)
callbackRef.current()
}, [callbackRef, setCommandMenuOpen])
useRegisterCommands(
COMMAND_MENU_SECTIONS.SHORTCUTS,
@@ -99,6 +103,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor
{
enabled: enabledInCommandMenu,
deps: depsInCommandMenu,
sectionMeta: { priority: 1 },
}
)
}
+5
View File
@@ -36,6 +36,11 @@
box-shadow: inset 0 0 0 1px #24b47e;
}
.rdg:not(:focus-within) .rdg-cell[aria-selected='true'] {
box-shadow: none;
outline: none;
}
/* Cell with unsaved changes - warning (amber) background/text color */
.rdg-cell.rdg-cell--dirty {
background-color: hsl(var(--warning-300) / 0.75);