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