mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 22:18:00 +08:00
feat(studio): improve keyboard shortcuts reference (#45352)
## What kind of change does this PR introduce? Feature improvement to the Studio keyboard shortcuts reference and command palette behaviour. ## What is the current behavior? The keyboard shortcuts sheet does not support filtering, some shortcut labels are harder to scan at a glance, and the command palette shows "Show all keyboard shortcuts" before the more contextual shortcuts in the `Shortcuts` section. ## What is the new behavior? Adds live filtering to the keyboard shortcuts sheet, keeps the sheet width stable on small breakpoints, renders arrow-based shortcuts more compactly, and moves "Show all keyboard shortcuts" to the end of the `Shortcuts` section so contextual actions appear first. https://github.com/user-attachments/assets/315a1a36-0cfb-4a0d-b6de-ef3c86aa9a05 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added search for keyboard shortcuts with live filtering, group-aware results, clear-search action, and empty-state handling * Added arrow key symbols for clearer shortcut visuals * **Improvements** * Updated shortcut visuals and typography for a tighter, pill-style presentation * Improved command menu ordering so shortcut-related entries appear in a logical sequence * **Tests** * Added tests covering shortcut search behavior, display formatting, and platform-specific key rendering <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ShortcutsReferenceSheet } from './ShortcutsReferenceSheet'
|
||||
import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
|
||||
const NAVIGATION_LABELS = Object.values(SHORTCUT_DEFINITIONS)
|
||||
.filter((definition) => definition.id.startsWith('nav.'))
|
||||
.map((definition) => definition.label)
|
||||
|
||||
const renderShortcutsReferenceSheet = () => {
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
customRender(<ShortcutsReferenceSheet open onOpenChange={onOpenChange} />)
|
||||
|
||||
return { onOpenChange }
|
||||
}
|
||||
|
||||
describe('ShortcutsReferenceSheet', () => {
|
||||
it('renders the grouped shortcut list by default', async () => {
|
||||
renderShortcutsReferenceSheet()
|
||||
|
||||
expect(await screen.findByText('Keyboard shortcuts')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Search shortcuts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Command Menu')).toBeInTheDocument()
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
||||
expect(screen.getByText('Open command menu')).toBeInTheDocument()
|
||||
expect(screen.getByText('Go to Project Overview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows every shortcut in a group when the group label matches the search', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderShortcutsReferenceSheet()
|
||||
|
||||
await user.type(screen.getByLabelText('Search shortcuts'), 'navigation')
|
||||
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Command Menu')).not.toBeInTheDocument()
|
||||
|
||||
for (const label of NAVIGATION_LABELS) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the parent group header when only an item label matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderShortcutsReferenceSheet()
|
||||
|
||||
await user.type(screen.getByLabelText('Search shortcuts'), 'Go to Organization Integrations')
|
||||
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
||||
expect(screen.getByText('Go to Organization Integrations')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Go to Logs')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Command Menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a clear button when searching and resets the list when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderShortcutsReferenceSheet()
|
||||
|
||||
await user.type(screen.getByLabelText('Search shortcuts'), 'navigation')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
|
||||
expect(screen.getByLabelText('Search shortcuts')).toHaveValue('')
|
||||
expect(screen.getByText('Command Menu')).toBeInTheDocument()
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['⌘Esc', 'Mod+/'])('does not search shortcut values like %s', async (query) => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderShortcutsReferenceSheet()
|
||||
|
||||
await user.type(screen.getByLabelText('Search shortcuts'), query)
|
||||
|
||||
expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Navigation')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state when nothing matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderShortcutsReferenceSheet()
|
||||
|
||||
await user.type(screen.getByLabelText('Search shortcuts'), 'totally missing')
|
||||
|
||||
expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,16 @@
|
||||
import { Fragment } from 'react'
|
||||
import { KeyboardShortcut, Sheet, SheetContent, SheetHeader, SheetSection, SheetTitle } from 'ui'
|
||||
import { CircleX } from 'lucide-react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
KeyboardShortcut,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
|
||||
import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry'
|
||||
@@ -10,6 +21,12 @@ interface ShortcutsReferenceSheetProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
group: string
|
||||
label: string
|
||||
definitions: ShortcutDefinition[]
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
'action-bar': 'Actions',
|
||||
'ai-assistant': 'AI Assistant',
|
||||
@@ -43,7 +60,11 @@ const getGroupOrder = (group: string) => {
|
||||
return index === -1 ? GROUP_ORDER.length : index
|
||||
}
|
||||
|
||||
const groupDefinitions = (): Array<{ group: string; definitions: ShortcutDefinition[] }> => {
|
||||
const getGroupLabel = (group: string) => GROUP_LABELS[group] ?? group
|
||||
|
||||
const normalizeSearchValue = (value: string) => value.trim().toLowerCase()
|
||||
|
||||
const groupDefinitions = (): ShortcutGroup[] => {
|
||||
const grouped = Object.values(SHORTCUT_DEFINITIONS).reduce<Record<string, ShortcutDefinition[]>>(
|
||||
(acc, definition) => {
|
||||
const prefix = definition.id.split('.')[0]
|
||||
@@ -57,50 +78,108 @@ const groupDefinitions = (): Array<{ group: string; definitions: ShortcutDefinit
|
||||
return Object.entries(grouped)
|
||||
.map(([group, definitions]) => ({
|
||||
group,
|
||||
label: getGroupLabel(group),
|
||||
definitions,
|
||||
}))
|
||||
.sort((a, b) => getGroupOrder(a.group) - getGroupOrder(b.group))
|
||||
}
|
||||
|
||||
const filterGroups = (groups: ShortcutGroup[], search: string) => {
|
||||
const normalizedSearch = normalizeSearchValue(search)
|
||||
|
||||
if (normalizedSearch.length === 0) return groups
|
||||
|
||||
return groups.reduce<ShortcutGroup[]>((acc, group) => {
|
||||
if (normalizeSearchValue(group.label).includes(normalizedSearch)) {
|
||||
acc.push(group)
|
||||
return acc
|
||||
}
|
||||
|
||||
const definitions = group.definitions.filter((definition) =>
|
||||
normalizeSearchValue(definition.label).includes(normalizedSearch)
|
||||
)
|
||||
|
||||
if (definitions.length > 0) {
|
||||
acc.push({ ...group, definitions })
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
const ShortcutSequence = ({ sequence }: Pick<ShortcutDefinition, 'sequence'>) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{sequence.map((step, index) => (
|
||||
<Fragment key={`${step}-${index}`}>
|
||||
{index > 0 && <span className="text-foreground-lighter text-[11px]">then</span>}
|
||||
<KeyboardShortcut keys={hotkeyToKeys(step)} />
|
||||
<KeyboardShortcut keys={hotkeyToKeys(step)} variant="pill" />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export function ShortcutsReferenceSheet({ open, onOpenChange }: ShortcutsReferenceSheetProps) {
|
||||
const groups = groupDefinitions()
|
||||
const [search, setSearch] = useState('')
|
||||
const groups = filterGroups(groupDefinitions(), search)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSearch('')
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col gap-0 p-0 sm:max-w-[520px]">
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-[520px]">
|
||||
<SheetHeader className="shrink-0 py-3">
|
||||
<SheetTitle>Keyboard shortcuts</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Browse and search available keyboard shortcuts.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="shrink-0 bg-studio px-5 pt-4 pb-4">
|
||||
<Input
|
||||
aria-label="Search shortcuts"
|
||||
autoFocus={open}
|
||||
className="w-full"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search shortcuts..."
|
||||
value={search}
|
||||
actions={
|
||||
search ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<CircleX size={14} />}
|
||||
onClick={() => setSearch('')}
|
||||
className="h-5 w-5 p-0"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetSection className="flex flex-1 flex-col gap-6 overflow-y-auto px-5 py-4">
|
||||
{groups.map(({ group, definitions }) => (
|
||||
<section key={group} className="flex flex-col gap-2">
|
||||
<h3 className="text-xs text-foreground-lighter uppercase tracking-wider">
|
||||
{GROUP_LABELS[group] ?? group}
|
||||
</h3>
|
||||
<ul className="flex flex-col">
|
||||
{definitions.map((definition) => (
|
||||
<li
|
||||
key={definition.id}
|
||||
className="flex min-h-10 items-center justify-between gap-4 border-b py-2 last:border-b-0"
|
||||
>
|
||||
<span className="text-sm text-foreground">{definition.label}</span>
|
||||
<ShortcutSequence sequence={definition.sequence} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-foreground-muted">No matching shortcuts found</p>
|
||||
) : (
|
||||
groups.map(({ group, label, definitions }) => (
|
||||
<section key={group} className="flex flex-col gap-2">
|
||||
<h3 className="text-xs text-foreground-lighter uppercase tracking-wider">
|
||||
{label}
|
||||
</h3>
|
||||
<ul className="flex flex-col">
|
||||
{definitions.map((definition) => (
|
||||
<li
|
||||
key={definition.id}
|
||||
className="flex min-h-10 items-center justify-between gap-4 border-b border-muted py-2 last:border-b-0"
|
||||
>
|
||||
<span className="text-sm text-foreground">{definition.label}</span>
|
||||
<ShortcutSequence sequence={definition.sequence} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</SheetSection>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import type { ICommand } from 'ui-patterns/CommandMenu/api/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from './registry'
|
||||
@@ -41,7 +42,7 @@ const getLastRegisterCall = () => {
|
||||
return call as [
|
||||
string,
|
||||
Array<{ id: string; name: string; action: () => void; badge: () => any }>,
|
||||
{ enabled: boolean; deps: unknown[] },
|
||||
{ enabled: boolean; deps: unknown[]; orderCommands?: unknown },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -247,6 +248,32 @@ describe('useShortcut', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('orders "Show all keyboard shortcuts" last within the Shortcuts section', () => {
|
||||
renderHook(() =>
|
||||
useShortcut(SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, vi.fn(), { registerInCommandMenu: true })
|
||||
)
|
||||
|
||||
const [, commands, options] = getLastRegisterCall()
|
||||
const orderCommands = options.orderCommands as (
|
||||
existing: ICommand[],
|
||||
commandsToInsert: ICommand[]
|
||||
) => ICommand[]
|
||||
|
||||
const ordered = orderCommands(
|
||||
[
|
||||
{ id: SHORTCUT_IDS.TABLE_EDITOR_INSERT_ROW, name: 'Insert row', action: vi.fn() },
|
||||
{ id: SHORTCUT_IDS.TABLE_EDITOR_INSERT_COLUMN, name: 'Insert column', action: vi.fn() },
|
||||
],
|
||||
commands
|
||||
)
|
||||
|
||||
expect(ordered.map((command) => command.id)).toEqual([
|
||||
SHORTCUT_IDS.TABLE_EDITOR_INSERT_ROW,
|
||||
SHORTCUT_IDS.TABLE_EDITOR_INSERT_COLUMN,
|
||||
SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE,
|
||||
])
|
||||
})
|
||||
|
||||
describe('badge rendering', () => {
|
||||
it('renders a single KeyboardShortcut pill for single-step sequences (no "then")', () => {
|
||||
renderHook(() =>
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useHotkeySequence } from '@tanstack/react-hotkeys'
|
||||
import { Fragment, useCallback } from 'react'
|
||||
import { KeyboardShortcut } from 'ui'
|
||||
import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
|
||||
import type { ICommand } from 'ui-patterns/CommandMenu/api/types'
|
||||
|
||||
import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry'
|
||||
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS, type ShortcutId } from './registry'
|
||||
import type { ShortcutOptions } from './types'
|
||||
import { useIsShortcutEnabled } from './useIsShortcutEnabled'
|
||||
import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
|
||||
@@ -12,6 +13,16 @@ import useLatest from '@/hooks/misc/useLatest'
|
||||
const hotkeyToKeys = (hotkey: string): string[] =>
|
||||
hotkey.split('+').map((part) => (part === 'Mod' ? 'Meta' : part))
|
||||
|
||||
const orderShortcutCommands = (commands: ICommand[], commandsToInsert: ICommand[]): ICommand[] => {
|
||||
const mergedCommands = [...commands, ...commandsToInsert]
|
||||
|
||||
return mergedCommands.sort((a, b) => {
|
||||
if (a.id === SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE) return 1
|
||||
if (b.id === SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE) return -1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a registered keyboard shortcut.
|
||||
*
|
||||
@@ -103,6 +114,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor
|
||||
{
|
||||
enabled: enabledInCommandMenu,
|
||||
deps: depsInCommandMenu,
|
||||
orderCommands: orderShortcutCommands,
|
||||
sectionMeta: { priority: 1 },
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { KeyboardShortcut } from './KeyboardShortcut'
|
||||
|
||||
const originalNavigator = global.navigator
|
||||
|
||||
const setNavigator = (platform: string, userAgent: string) => {
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalNavigator,
|
||||
platform,
|
||||
userAgent,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('KeyboardShortcut', () => {
|
||||
afterEach(() => {
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
configurable: true,
|
||||
value: originalNavigator,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders arrow keys as symbols', () => {
|
||||
render(<KeyboardShortcut keys={['ArrowUp']} />)
|
||||
|
||||
expect(screen.getByText('↑')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders compact mac-style shortcuts for symbol and single-character keys', () => {
|
||||
setNavigator('MacIntel', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)')
|
||||
|
||||
render(<KeyboardShortcut keys={['Meta', 'ArrowUp']} />)
|
||||
|
||||
expect(screen.getByText('⌘↑')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps word-style non-mac shortcuts readable', () => {
|
||||
setNavigator('Linux x86_64', 'Mozilla/5.0 (X11; Linux x86_64)')
|
||||
|
||||
render(<KeyboardShortcut keys={['Meta', 'ArrowUp']} />)
|
||||
|
||||
expect(screen.getByText('Ctrl ↑')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,10 @@ const KEY_SYMBOLS: Record<string, string | ((isMac: boolean) => string)> = {
|
||||
Alt: (isMac) => (isMac ? '⌥' : 'Alt'),
|
||||
Shift: '⇧',
|
||||
Enter: '↵',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Esc: 'Esc', // ⎋ symbol not recognisable enough
|
||||
Escape: 'Esc', // Match above
|
||||
Tab: 'Tab', // ⇥ symbol not recognisable enough
|
||||
@@ -36,17 +40,23 @@ const resolveKeyLabel = (key: string, isMac: boolean) => {
|
||||
return resolvedKey.length === 1 ? resolvedKey.toUpperCase() : resolvedKey
|
||||
}
|
||||
|
||||
const formatShortcutLabel = (keys: string[]) => {
|
||||
if (keys.length <= 1) return keys.join('')
|
||||
|
||||
return keys.every((key) => key.length === 1) ? keys.join('') : keys.join(' ')
|
||||
}
|
||||
|
||||
export const KeyboardShortcut = ({ keys, variant = 'pill', className }: KeyboardShortcutProps) => {
|
||||
const isMac = getIsMac()
|
||||
const resolvedKeys = keys.map((key) => resolveKeyLabel(key, isMac))
|
||||
const shortcutLabel = resolvedKeys.join(' ')
|
||||
const shortcutLabel = formatShortcutLabel(resolvedKeys)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex whitespace-nowrap shrink-0',
|
||||
variant === 'pill'
|
||||
? 'items-center text-[11px] leading-none tracking-tighter text-foreground-light bg-surface-200 dark:bg-surface-100 rounded-sm px-[5px] py-[3px]'
|
||||
? 'items-center text-[11px] leading-none tracking-[-0.025em] text-foreground-light bg-surface-200/50 dark:bg-surface-100/50 rounded px-[5px] py-[3px] border border-border-muted'
|
||||
: 'items-baseline text-[11px] leading-[inherit] text-foreground/40',
|
||||
className
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user