Перейти к основному содержимому
On this page

Central registry for all keyboard shortcuts in the app shell. Enables discoverability, conflict resolution, and accessibility.


Shortcut Registry

Global Shortcuts (Always Active)

Shortcut Action Component
⌘K / Ctrl+K Open QuickSearch QuickSearch
⌘J / Ctrl+J Open AI Assistant AI Assistant
? Open keyboard shortcuts help ShortcutsModal
Escape Close current modal/overlay Global

Navigation Shortcuts

Shortcut Action Context
G then H Go to Home/Dashboard Global
G then S Go to Settings Global
G then N Go to Notifications Global
G then P Go to Profile Global

QuickSearch Shortcuts (When Open)

Shortcut Action
/ Navigate results
Enter Select highlighted item
Tab Cycle through categories
Escape Close QuickSearch

AI Assistant Shortcuts (When Open)

Shortcut Action
Enter Send message
Shift+Enter New line
⌘↑ / Ctrl+↑ Previous message (edit)
Escape Close AI Assistant

Form Shortcuts

Shortcut Action Context
⌘S / Ctrl+S Save form When form is focused
⌘Enter / Ctrl+Enter Submit form When form is focused
Escape Cancel/close form modal In modal forms

Shortcuts Help Modal

Triggered by pressing ? from anywhere in the app. Shows all available shortcuts organized by category.

Wireframe

┌─────────────────────────────────────────────────────────────┐
│ Keyboard Shortcuts                                     [✕]  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│ General                                                     │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ⌘K        Open QuickSearch                              ││
│ │ ⌘J        Open AI Assistant                             ││
│ │ ?         Show this help                                ││
│ │ Esc       Close modal/overlay                           ││
│ └─────────────────────────────────────────────────────────┘│
│                                                             │
│ Navigation                                                  │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ G H       Go to Home                                    ││
│ │ G S       Go to Settings                                ││
│ │ G N       Go to Notifications                           ││
│ └─────────────────────────────────────────────────────────┘│
│                                                             │
│ Forms                                                       │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ⌘S        Save form                                     ││
│ │ ⌘Enter    Submit form                                   ││
│ └─────────────────────────────────────────────────────────┘│
│                                                             │
│ ☐ Show shortcuts on startup                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Implementation

Shortcut Registry

// src/lib/shortcuts/registry.ts
export interface Shortcut {
  id: string;
  keys: string[];           // ['meta', 'k'] or ['g', 'h']
  label: string;
  description: string;
  category: 'general' | 'navigation' | 'forms' | 'search' | 'ai';
  action: () => void;
  when?: () => boolean;     // Context predicate
  preventDefault?: boolean;
}

const shortcuts = new Map<string, Shortcut>();

export function registerShortcut(shortcut: Shortcut) {
  const key = shortcut.keys.join('+');

  // Check for conflicts
  if (shortcuts.has(key)) {
    console.warn(`Shortcut conflict: ${key} already registered`);
  }

  shortcuts.set(key, shortcut);
  return () => shortcuts.delete(key); // Unregister function
}

export function getShortcuts(): Shortcut[] {
  return [...shortcuts.values()];
}

export function getShortcutsByCategory(): Record<string, Shortcut[]> {
  const byCategory: Record<string, Shortcut[]> = {};

  for (const shortcut of shortcuts.values()) {
    if (!byCategory[shortcut.category]) {
      byCategory[shortcut.category] = [];
    }
    byCategory[shortcut.category].push(shortcut);
  }

  return byCategory;
}

Keyboard Handler

// src/lib/shortcuts/handler.ts
import { browser } from '$app/environment';

let sequenceBuffer: string[] = [];
let sequenceTimeout: ReturnType<typeof setTimeout>;

export function initKeyboardHandler() {
  if (!browser) return;

  document.addEventListener('keydown', handleKeydown);

  return () => {
    document.removeEventListener('keydown', handleKeydown);
  };
}

function handleKeydown(event: KeyboardEvent) {
  // Ignore if user is typing in an input
  if (isTypingContext(event.target)) {
    // Allow specific shortcuts even in inputs
    if (!isAllowedInInput(event)) return;
  }

  const key = normalizeKey(event);

  // Handle sequence shortcuts (G then H)
  if (sequenceBuffer.length > 0 || key === 'g') {
    handleSequence(key, event);
    return;
  }

  // Handle direct shortcuts
  handleDirect(event);
}

function isTypingContext(target: EventTarget | null): boolean {
  if (!target || !(target instanceof HTMLElement)) return false;

  const tagName = target.tagName.toLowerCase();
  const isEditable = target.isContentEditable;
  const isInput = ['input', 'textarea', 'select'].includes(tagName);

  return isInput || isEditable;
}

function isAllowedInInput(event: KeyboardEvent): boolean {
  // Allow Escape, Cmd+K, Cmd+J even in inputs
  if (event.key === 'Escape') return true;
  if ((event.metaKey || event.ctrlKey) && ['k', 'j'].includes(event.key.toLowerCase())) {
    return true;
  }
  return false;
}

function handleSequence(key: string, event: KeyboardEvent) {
  clearTimeout(sequenceTimeout);
  sequenceBuffer.push(key);

  // Find matching shortcut
  const sequenceKey = sequenceBuffer.join('+');
  const shortcut = shortcuts.get(sequenceKey);

  if (shortcut) {
    if (!shortcut.when || shortcut.when()) {
      event.preventDefault();
      shortcut.action();
    }
    sequenceBuffer = [];
    return;
  }

  // Reset after timeout (500ms between keys)
  sequenceTimeout = setTimeout(() => {
    sequenceBuffer = [];
  }, 500);
}

function handleDirect(event: KeyboardEvent) {
  const keys: string[] = [];

  if (event.metaKey) keys.push('meta');
  if (event.ctrlKey) keys.push('ctrl');
  if (event.altKey) keys.push('alt');
  if (event.shiftKey) keys.push('shift');
  keys.push(event.key.toLowerCase());

  const key = keys.join('+');
  const shortcut = shortcuts.get(key);

  if (shortcut && (!shortcut.when || shortcut.when())) {
    if (shortcut.preventDefault !== false) {
      event.preventDefault();
    }
    shortcut.action();
  }
}

function normalizeKey(event: KeyboardEvent): string {
  return event.key.toLowerCase();
}

Shell Integration

<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
  import { onMount } from 'svelte';
  import { goto } from '$app/navigation';
  import { initKeyboardHandler, registerShortcut } from '$lib/shortcuts';
  import { ShortcutsModal } from '$lib/components/shell';

  let showShortcuts = $state(false);
  let showQuickSearch = $state(false);
  let showAiAssistant = $state(false);

  onMount(() => {
    const cleanup = initKeyboardHandler();

    // Register global shortcuts
    registerShortcut({
      id: 'quick-search',
      keys: ['meta', 'k'],
      label: '⌘K',
      description: 'Open QuickSearch',
      category: 'general',
      action: () => { showQuickSearch = true; },
    });

    registerShortcut({
      id: 'ai-assistant',
      keys: ['meta', 'j'],
      label: '⌘J',
      description: 'Open AI Assistant',
      category: 'general',
      action: () => { showAiAssistant = true; },
    });

    registerShortcut({
      id: 'shortcuts-help',
      keys: ['?'],
      label: '?',
      description: 'Show keyboard shortcuts',
      category: 'general',
      action: () => { showShortcuts = true; },
      when: () => !showQuickSearch && !showAiAssistant, // Only when no modal open
    });

    registerShortcut({
      id: 'go-home',
      keys: ['g', 'h'],
      label: 'G H',
      description: 'Go to Home',
      category: 'navigation',
      action: () => goto('/app/dashboard'),
    });

    registerShortcut({
      id: 'go-settings',
      keys: ['g', 's'],
      label: 'G S',
      description: 'Go to Settings',
      category: 'navigation',
      action: () => goto('/app/settings'),
    });

    return cleanup;
  });
</script>

<ShortcutsModal bind:open={showShortcuts} />

Shortcuts Modal Component

<!-- src/lib/components/shell/ShortcutsModal.svelte -->
<script lang="ts">
  import { Dialog } from 'bits-ui';
  import { getShortcutsByCategory } from '$lib/shortcuts';

  let { open = $bindable(false) } = $props();

  const categories = $derived(getShortcutsByCategory());

  const categoryLabels: Record<string, string> = {
    general: 'General',
    navigation: 'Navigation',
    forms: 'Forms',
    search: 'Search',
    ai: 'AI Assistant',
  };
</script>

<Dialog.Root bind:open>
  <Dialog.Portal>
    <Dialog.Overlay class="fixed inset-0 bg-black/50 z-modal-overlay" />
    <Dialog.Content class="fixed inset-4 md:inset-auto md:top-1/2 md:left-1/2 md:-translate-x-1/2 md:-translate-y-1/2 md:max-w-lg md:w-full bg-surface rounded-lg shadow-xl z-modal overflow-hidden">
      <div class="flex items-center justify-between p-4 border-b">
        <Dialog.Title class="text-lg font-semibold">
          Keyboard Shortcuts
        </Dialog.Title>
        <Dialog.Close class="btn-icon">
          <span class="i-lucide-x" />
        </Dialog.Close>
      </div>

      <div class="p-4 max-h-[60vh] overflow-y-auto space-y-6">
        {#each Object.entries(categories) as [category, shortcuts]}
          <div>
            <h3 class="text-sm font-medium text-muted mb-2">
              {categoryLabels[category] ?? category}
            </h3>
            <div class="space-y-1">
              {#each shortcuts as shortcut}
                <div class="flex items-center justify-between py-1.5">
                  <span class="text-sm">{shortcut.description}</span>
                  <kbd class="shortcut-key">{shortcut.label}</kbd>
                </div>
              {/each}
            </div>
          </div>
        {/each}
      </div>

      <div class="p-4 border-t bg-muted/30">
        <label class="flex items-center gap-2 text-sm">
          <input type="checkbox" class="checkbox" />
          Show shortcuts on startup
        </label>
      </div>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

<style>
  .shortcut-key {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    padding: 0.25rem 0.5rem;
    background: var(--color-muted);
    border-radius: 0.25rem;
    border: 1px solid var(--color-border);
  }
</style>

Conflict Resolution

Priority Rules

  1. Modal shortcuts take precedence when modal is open
  2. Input context disables most shortcuts (except Escape, ⌘K, ⌘J)
  3. Component-specific shortcuts only active when component is mounted
  4. Sequence shortcuts (G+H) have lower priority than direct shortcuts

Handling Conflicts

// When registering, check for conflicts
export function registerShortcut(shortcut: Shortcut) {
  const key = shortcut.keys.join('+');

  const existing = shortcuts.get(key);
  if (existing) {
    // Option 1: Warn and override
    console.warn(`Shortcut ${key} replaced: ${existing.id} -> ${shortcut.id}`);

    // Option 2: Context-based coexistence
    // Both can exist if their `when` predicates don't overlap
  }

  shortcuts.set(key, shortcut);
}

Platform Detection

Display platform-appropriate symbols:

// src/lib/shortcuts/platform.ts
import { browser } from '$app/environment';

export function isMac(): boolean {
  if (!browser) return false;
  return navigator.platform.toLowerCase().includes('mac');
}

export function formatShortcut(keys: string[]): string {
  const mac = isMac();

  return keys.map(key => {
    switch (key) {
      case 'meta': return mac ? '⌘' : 'Ctrl';
      case 'ctrl': return mac ? '⌃' : 'Ctrl';
      case 'alt': return mac ? '⌥' : 'Alt';
      case 'shift': return mac ? '⇧' : 'Shift';
      case 'enter': return mac ? '↵' : 'Enter';
      case 'escape': return 'Esc';
      case 'arrowup': return '↑';
      case 'arrowdown': return '↓';
      default: return key.toUpperCase();
    }
  }).join(mac ? '' : '+');
}

Discoverability

Inline Hints

Show keyboard hints in UI elements:

<!-- Button with shortcut hint -->
<button class="btn">
  <span class="i-lucide-search" />
  Search
  <kbd class="shortcut-hint">⌘K</kbd>
</button>

<style>
  .shortcut-hint {
    margin-left: auto;
    opacity: 0.5;
    font-size: 0.75rem;
  }
</style>

Tooltip Hints

<!-- Tooltip showing shortcut -->
<Tooltip.Root>
  <Tooltip.Trigger>
    <button class="btn-icon">
      <span class="i-lucide-settings" />
    </button>
  </Tooltip.Trigger>
  <Tooltip.Content>
    Settings <kbd>G S</kbd>
  </Tooltip.Content>
</Tooltip.Root>

First-Run Overlay

For new users, show a brief overlay highlighting key shortcuts:

{#if showFirstRunHints}
  <div class="fixed inset-0 bg-black/70 z-overlay">
    <div class="absolute top-4 right-4 text-white">
      <p>Press <kbd>⌘K</kbd> to search</p>
      <p>Press <kbd>⌘J</kbd> for AI help</p>
      <p>Press <kbd>?</kbd> for all shortcuts</p>
      <button onclick={() => showFirstRunHints = false}>Got it</button>
    </div>
  </div>
{/if}

Accessibility

Requirement Implementation
Screen reader Announce shortcut when focused
Settings Option to disable all shortcuts
Documentation aria-keyshortcuts attribute on elements
Alternative All actions accessible via mouse/touch
<!-- Accessible button with shortcut -->
<button
  aria-keyshortcuts="Control+k"
  aria-label="Search (Control+K)"
>
  Search
</button>

Disable Shortcuts Setting

// Check user setting before executing
function handleKeydown(event: KeyboardEvent) {
  if (!userSettings.enableKeyboardShortcuts) return;
  // ... rest of handler
}

Component Location

src/lib/
├── shortcuts/
│   ├── registry.ts       # Shortcut registration
│   ├── handler.ts        # Keyboard event handling
│   ├── platform.ts       # Platform detection
│   └── index.ts          # Exports
└── components/
    └── shell/
        └── ShortcutsModal.svelte

← Back to Blueprint