Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Hotkeys API Reference
App Hotkeys API Reference
Key hold & held keys API Reference
Hotkey Recorder API Reference
Normalization & format API Reference

Alpine Example: Kitchen Sink

import Alpine from 'alpinejs'
import {
  createHotkeysScope,
  formatForDisplay,
  isHotkeySequence,
} from '@tanstack/alpine-hotkeys'
import type {
  AlpineHotkeyRecorder,
  AlpineHotkeyState,
  Hotkey,
  HotkeyRegistrationsResult,
  RecorderKeyMode,
  RegisterableHotkey,
} from '@tanstack/alpine-hotkeys'
import './index.css'

const pages = [
  { to: '/', label: 'Tickets', shortcut: 'Alt+[Digit1]' },
  { to: '/editor', label: 'Editor', shortcut: 'Alt+[Digit2]' },
  { to: '/sequences', label: 'Sequences', shortcut: 'Alt+[Digit3]' },
  { to: '/recording', label: 'Recording', shortcut: 'Alt+[Digit4]' },
  { to: '/formatting', label: 'Formatting', shortcut: 'Alt+[Digit5]' },
] as const

class Hint {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  visible!: AlpineHotkeyState<boolean>
  constructor(
    public props: () => {
      hotkey: RegisterableHotkey
      enabled?: boolean
    },
  ) {}
  init() {
    this.visible = this.hotkeysScope.createHotkeyHint(() => this.props().hotkey)
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Layout {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })
  private cleanups: Array<() => void> = []
  formatForDisplay = formatForDisplay
  pages = pages
  currentPath: string = window.location.pathname
  showShortcuts = false
  activity: Array<string> = []
  keys!: AlpineHotkeyState<Array<string>>
  codes!: AlpineHotkeyState<Record<string, string>>
  shift!: AlpineHotkeyState<boolean>
  registrationState0!: HotkeyRegistrationsResult
  navigate({ to }: { to: string }) {
    window.history.pushState({}, '', to)
    this.currentPath = to
  }
  log(message: string) {
    this.activity = [message, ...this.activity].slice(0, 6)
  }
  get hotkeys() {
    return this.registrationState0.hotkeys
  }
  get registrations() {
    return this.hotkeys
  }
  get groups() {
    return [
      ...new Set(
        this.registrations.map((reg) => reg.options.meta?.group ?? 'Other'),
      ),
    ]
  }

  init() {
    this.navigate = this.navigate.bind(this)
    this.log = this.log.bind(this)
    this.keys = this.hotkeysScope.createHeldKeys()
    this.codes = this.hotkeysScope.createHeldKeyCodes()
    this.shift = this.hotkeysScope.createKeyHold('Shift')
    this.registrationState0 = this.hotkeysScope.createHotkeyRegistrations()
    this.hotkeysScope.createHotkeys(
      () =>
        pages.map((page) => ({
          hotkey: page.shortcut,
          callback: () => {
            void this.navigate({ to: page.to })
          },
          options: {
            meta: { name: `Open ${page.label}`, group: 'Navigation' },
          },
        })),
      { ignoreInputs: true },
    )
    this.hotkeysScope.createHotkey(
      'Alt+Shift+[KeyK]',
      () => (this.showShortcuts = !this.showShortcuts),
      {
        meta: { name: 'Show shortcuts', group: 'Navigation' },
      },
    )
    const onPopState = () => {
      this.currentPath = window.location.pathname
    }
    window.addEventListener('popstate', onPopState)
    this.cleanups.push(() => window.removeEventListener('popstate', onPopState))
  }
  destroy() {
    for (const cleanup of this.cleanups) cleanup()
    this.hotkeysScope.destroy()
  }
}

class Tickets {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  count = 0
  enabled = true
  create() {
    this.count = this.count + 1
    this.props().log('Ticket created')
  }
  save() {
    return this.props().log('Pending ticket saved')
  }
  constructor(public props: () => { log: (message: string) => void }) {}
  init() {
    this.create = this.create.bind(this)
    this.save = this.save.bind(this)
    this.hotkeysScope.createHotkey('Alt+[KeyC]', this.create, () => ({
      enabled: this.enabled,
      ignoreInputs: false,
      meta: {
        name: 'Create ticket',
        description: 'Also works while entering a ticket note',
        group: 'Tickets',
      },
    }))
    this.hotkeysScope.createHotkey('Alt+[KeyS]', this.save, () => ({
      enabled: this.enabled,
      meta: { name: 'Save pending ticket', group: 'Tickets' },
    }))
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class EditorPane {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  target: HTMLFieldSetElement | null = null
  text = 'One shortcut, two independent editors.'
  saves = 0

  constructor(
    public props: () => { name: string; log: (message: string) => void },
  ) {}
  init() {
    this.hotkeysScope.createHotkeys(
      [
        {
          hotkey: 'Mod+[KeyS]',
          callback: () => {
            this.saves = this.saves + 1
            this.props().log(`${this.props().name} saved`)
          },
          options: {
            meta: {
              name: `Save ${this.props().name}`,
              group: 'Editor',
              description: 'Limited to this editor',
            },
          },
        },
        {
          hotkey: 'Alt+[Backspace]',
          callback: () => {
            this.text = ''
            this.props().log(`${this.props().name} cleared`)
          },
          options: {
            meta: { name: `Clear ${this.props().name}`, group: 'Editor' },
          },
        },
      ],
      () => ({
        target: this.target,
        ignoreInputs: false,
      }),
    )
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Editor {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  repeat = false
  count = 0
  bubble = false
  scope: HTMLFieldSetElement | null = null

  constructor(public props: () => { log: (message: string) => void }) {}
  init() {
    this.hotkeysScope.createHotkey(
      'Alt+[ArrowRight]',
      () => (this.count = this.count + 1),
      () => ({
        requireReset: !this.repeat,
        meta: { name: 'Advance counter', group: 'Editor' },
      }),
    )
    this.hotkeysScope.createHotkey(
      'Alt+[KeyB]',
      () => this.props().log('Target B handler'),
      () => ({
        target: this.scope,
        stopPropagation: !this.bubble,
        preventDefault: !this.bubble,
        meta: { name: 'Target propagation demo', group: 'Editor' },
      }),
    )
    this.hotkeysScope.createHotkey(
      'Alt+[KeyB]',
      () => this.props().log('Document B handler received the bubbled event'),
      {
        meta: { name: 'Document propagation demo', group: 'Editor' },
      },
    )
    this.hotkeysScope.createHotkey(
      'Alt+[KeyU]',
      () => this.props().log('Key released: keyup handler'),
      {
        eventType: 'keyup',
        meta: { name: 'Run on release', group: 'Editor' },
      },
    )
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Sequences {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  timeout = 1000
  registrationState2!: HotkeyRegistrationsResult
  get sequences() {
    return this.registrationState2.hotkeys.filter(
      (reg) => reg.kind === 'sequence',
    )
  }
  constructor(public props: () => { log: (message: string) => void }) {}
  init() {
    // An array of chords registers a sequence.
    this.hotkeysScope.createHotkey(
      ['[KeyG]', '[KeyG]'],
      () => this.props().log('Sequence: go to top'),
      () => ({
        timeout: this.timeout,
        meta: { name: 'Go to top', group: 'Sequences' },
      }),
    )
    this.hotkeysScope.createHotkeys(
      [
        {
          hotkey: ['[KeyG]', '[KeyI]'],
          callback: () => this.props().log('Sequence: inbox'),
          options: { meta: { name: 'Open inbox', group: 'Sequences' } },
        },
        {
          hotkey: ['Shift+[KeyR]', 'Shift+[KeyT]'],
          callback: () => this.props().log('Sequence: shifted chord chain'),
          options: { meta: { name: 'Shifted chain', group: 'Sequences' } },
        },
      ],
      () => ({ timeout: this.timeout }),
    )
    this.registrationState2 = this.hotkeysScope.createHotkeyRegistrations()
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Recording {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  hotkey: Hotkey = this.initial
  mode: RecorderKeyMode = 'code'
  problem = ''
  sequence: Hotkey = ['[KeyX]', '[KeyY]']
  sequenceProblem = ''
  idle = false
  commitOnEnter = true
  recorder!: AlpineHotkeyRecorder
  sequenceRecorder!: AlpineHotkeyRecorder
  get initial(): Hotkey {
    return 'Alt+[KeyR]'
  }
  constructor(public props: () => { log: (message: string) => void }) {}
  startRecording() {
    this.problem = ''
    this.recorder.startRecording()
  }
  resetShortcut() {
    this.recorder.stopRecording()
    this.hotkey = this.initial
    this.problem = ''
  }
  startSequenceRecording() {
    this.sequenceProblem = ''
    this.sequenceRecorder.startRecording()
  }
  init() {
    this.hotkeysScope.createHotkey(
      () => this.hotkey,
      () => this.props().log('Your recorded shortcut fired'),
      {
        meta: { name: 'Recorded action', group: 'Recording' },
      },
    )
    this.hotkeysScope.createHotkey(
      () => this.sequence,
      () => this.props().log('Your recorded sequence fired'),
      {
        meta: { name: 'Recorded sequence', group: 'Recording' },
      },
    )
    this.recorder = this.hotkeysScope.createHotkeyRecorder(() => ({
      recordBy: this.mode,
      ignoreInputs: false,
      detectConflicts: {
        exclude: (reg) => reg.options.meta?.name === 'Recorded action',
      },
      validate: (_, context) =>
        (context.kind === 'chord' &&
          context.parsedChord.modifiers.length > 0) ||
        'Include a modifier for this action.',
      onReject: ({ message }) => (this.problem = message),
      onRecord: (value) => {
        this.hotkey = value
        this.problem = ''
        this.props().log(`Recorded ${value}`)
      },
      onClear: () => {
        this.hotkey = this.initial
        this.problem = ''
        this.props().log('Restored default shortcut')
      },
      onCancel: () =>
        (this.problem = 'Recording cancelled. Binding unchanged.'),
    }))
    // maxSteps above 1 records sequences; one step records a chord.
    this.sequenceRecorder = this.hotkeysScope.createHotkeyRecorder(() => ({
      maxSteps: Infinity,
      recordBy: this.mode,
      ignoreInputs: false,
      idleTimeoutMs: this.idle ? 1500 : undefined,
      commitKeys: this.commitOnEnter ? 'enter' : 'none',
      detectConflicts: {
        exclude: (reg) => reg.options.meta?.name === 'Recorded sequence',
      },
      validate: (value) =>
        (isHotkeySequence(value) && value.length >= 2) ||
        'Record at least two chords.',
      onRecord: (value) => {
        this.sequence = value
        this.sequenceProblem = ''
        this.props().log(
          `Recorded sequence ${formatForDisplay(value, { sequenceSeparator: ' → ' })}`,
        )
      },
      onReject: (reason) => (this.sequenceProblem = reason.message),
      onClear: () => {
        this.sequence = ['[KeyX]', '[KeyY]']
        this.sequenceProblem = 'Restored default sequence.'
      },
      onCancel: () => (this.sequenceProblem = 'Recording cancelled.'),
    }))
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Formatting {
  formatForDisplay = formatForDisplay
  platform: 'mac' | 'windows' | 'linux' = 'mac'
  modifierSymbols = false
  keySymbols = true
  get options() {
    return {
      platform: this.platform,
      useSymbols: { modifiers: this.modifierSymbols, keys: this.keySymbols },
    }
  }
}
Alpine.data(
  'hint',
  (
    props: () => {
      hotkey: RegisterableHotkey
      enabled?: boolean
    },
  ) => new Hint(props),
)
Alpine.data('layout', () => new Layout())
Alpine.data(
  'tickets',
  (props: () => { log: (message: string) => void }) => new Tickets(props),
)
Alpine.data(
  'editorPane',
  (props: () => { name: string; log: (message: string) => void }) =>
    new EditorPane(props),
)
Alpine.data(
  'editor',
  (props: () => { log: (message: string) => void }) => new Editor(props),
)
Alpine.data(
  'sequences',
  (props: () => { log: (message: string) => void }) => new Sequences(props),
)
Alpine.data(
  'recording',
  (props: () => { log: (message: string) => void }) => new Recording(props),
)
Alpine.data('formatting', () => new Formatting())
Alpine.start()
import Alpine from 'alpinejs'
import {
  createHotkeysScope,
  formatForDisplay,
  isHotkeySequence,
} from '@tanstack/alpine-hotkeys'
import type {
  AlpineHotkeyRecorder,
  AlpineHotkeyState,
  Hotkey,
  HotkeyRegistrationsResult,
  RecorderKeyMode,
  RegisterableHotkey,
} from '@tanstack/alpine-hotkeys'
import './index.css'

const pages = [
  { to: '/', label: 'Tickets', shortcut: 'Alt+[Digit1]' },
  { to: '/editor', label: 'Editor', shortcut: 'Alt+[Digit2]' },
  { to: '/sequences', label: 'Sequences', shortcut: 'Alt+[Digit3]' },
  { to: '/recording', label: 'Recording', shortcut: 'Alt+[Digit4]' },
  { to: '/formatting', label: 'Formatting', shortcut: 'Alt+[Digit5]' },
] as const

class Hint {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  visible!: AlpineHotkeyState<boolean>
  constructor(
    public props: () => {
      hotkey: RegisterableHotkey
      enabled?: boolean
    },
  ) {}
  init() {
    this.visible = this.hotkeysScope.createHotkeyHint(() => this.props().hotkey)
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Layout {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })
  private cleanups: Array<() => void> = []
  formatForDisplay = formatForDisplay
  pages = pages
  currentPath: string = window.location.pathname
  showShortcuts = false
  activity: Array<string> = []
  keys!: AlpineHotkeyState<Array<string>>
  codes!: AlpineHotkeyState<Record<string, string>>
  shift!: AlpineHotkeyState<boolean>
  registrationState0!: HotkeyRegistrationsResult
  navigate({ to }: { to: string }) {
    window.history.pushState({}, '', to)
    this.currentPath = to
  }
  log(message: string) {
    this.activity = [message, ...this.activity].slice(0, 6)
  }
  get hotkeys() {
    return this.registrationState0.hotkeys
  }
  get registrations() {
    return this.hotkeys
  }
  get groups() {
    return [
      ...new Set(
        this.registrations.map((reg) => reg.options.meta?.group ?? 'Other'),
      ),
    ]
  }

  init() {
    this.navigate = this.navigate.bind(this)
    this.log = this.log.bind(this)
    this.keys = this.hotkeysScope.createHeldKeys()
    this.codes = this.hotkeysScope.createHeldKeyCodes()
    this.shift = this.hotkeysScope.createKeyHold('Shift')
    this.registrationState0 = this.hotkeysScope.createHotkeyRegistrations()
    this.hotkeysScope.createHotkeys(
      () =>
        pages.map((page) => ({
          hotkey: page.shortcut,
          callback: () => {
            void this.navigate({ to: page.to })
          },
          options: {
            meta: { name: `Open ${page.label}`, group: 'Navigation' },
          },
        })),
      { ignoreInputs: true },
    )
    this.hotkeysScope.createHotkey(
      'Alt+Shift+[KeyK]',
      () => (this.showShortcuts = !this.showShortcuts),
      {
        meta: { name: 'Show shortcuts', group: 'Navigation' },
      },
    )
    const onPopState = () => {
      this.currentPath = window.location.pathname
    }
    window.addEventListener('popstate', onPopState)
    this.cleanups.push(() => window.removeEventListener('popstate', onPopState))
  }
  destroy() {
    for (const cleanup of this.cleanups) cleanup()
    this.hotkeysScope.destroy()
  }
}

class Tickets {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  count = 0
  enabled = true
  create() {
    this.count = this.count + 1
    this.props().log('Ticket created')
  }
  save() {
    return this.props().log('Pending ticket saved')
  }
  constructor(public props: () => { log: (message: string) => void }) {}
  init() {
    this.create = this.create.bind(this)
    this.save = this.save.bind(this)
    this.hotkeysScope.createHotkey('Alt+[KeyC]', this.create, () => ({
      enabled: this.enabled,
      ignoreInputs: false,
      meta: {
        name: 'Create ticket',
        description: 'Also works while entering a ticket note',
        group: 'Tickets',
      },
    }))
    this.hotkeysScope.createHotkey('Alt+[KeyS]', this.save, () => ({
      enabled: this.enabled,
      meta: { name: 'Save pending ticket', group: 'Tickets' },
    }))
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class EditorPane {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  target: HTMLFieldSetElement | null = null
  text = 'One shortcut, two independent editors.'
  saves = 0

  constructor(
    public props: () => { name: string; log: (message: string) => void },
  ) {}
  init() {
    this.hotkeysScope.createHotkeys(
      [
        {
          hotkey: 'Mod+[KeyS]',
          callback: () => {
            this.saves = this.saves + 1
            this.props().log(`${this.props().name} saved`)
          },
          options: {
            meta: {
              name: `Save ${this.props().name}`,
              group: 'Editor',
              description: 'Limited to this editor',
            },
          },
        },
        {
          hotkey: 'Alt+[Backspace]',
          callback: () => {
            this.text = ''
            this.props().log(`${this.props().name} cleared`)
          },
          options: {
            meta: { name: `Clear ${this.props().name}`, group: 'Editor' },
          },
        },
      ],
      () => ({
        target: this.target,
        ignoreInputs: false,
      }),
    )
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Editor {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  repeat = false
  count = 0
  bubble = false
  scope: HTMLFieldSetElement | null = null

  constructor(public props: () => { log: (message: string) => void }) {}
  init() {
    this.hotkeysScope.createHotkey(
      'Alt+[ArrowRight]',
      () => (this.count = this.count + 1),
      () => ({
        requireReset: !this.repeat,
        meta: { name: 'Advance counter', group: 'Editor' },
      }),
    )
    this.hotkeysScope.createHotkey(
      'Alt+[KeyB]',
      () => this.props().log('Target B handler'),
      () => ({
        target: this.scope,
        stopPropagation: !this.bubble,
        preventDefault: !this.bubble,
        meta: { name: 'Target propagation demo', group: 'Editor' },
      }),
    )
    this.hotkeysScope.createHotkey(
      'Alt+[KeyB]',
      () => this.props().log('Document B handler received the bubbled event'),
      {
        meta: { name: 'Document propagation demo', group: 'Editor' },
      },
    )
    this.hotkeysScope.createHotkey(
      'Alt+[KeyU]',
      () => this.props().log('Key released: keyup handler'),
      {
        eventType: 'keyup',
        meta: { name: 'Run on release', group: 'Editor' },
      },
    )
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Sequences {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  timeout = 1000
  registrationState2!: HotkeyRegistrationsResult
  get sequences() {
    return this.registrationState2.hotkeys.filter(
      (reg) => reg.kind === 'sequence',
    )
  }
  constructor(public props: () => { log: (message: string) => void }) {}
  init() {
    // An array of chords registers a sequence.
    this.hotkeysScope.createHotkey(
      ['[KeyG]', '[KeyG]'],
      () => this.props().log('Sequence: go to top'),
      () => ({
        timeout: this.timeout,
        meta: { name: 'Go to top', group: 'Sequences' },
      }),
    )
    this.hotkeysScope.createHotkeys(
      [
        {
          hotkey: ['[KeyG]', '[KeyI]'],
          callback: () => this.props().log('Sequence: inbox'),
          options: { meta: { name: 'Open inbox', group: 'Sequences' } },
        },
        {
          hotkey: ['Shift+[KeyR]', 'Shift+[KeyT]'],
          callback: () => this.props().log('Sequence: shifted chord chain'),
          options: { meta: { name: 'Shifted chain', group: 'Sequences' } },
        },
      ],
      () => ({ timeout: this.timeout }),
    )
    this.registrationState2 = this.hotkeysScope.createHotkeyRegistrations()
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Recording {
  private hotkeysScope = createHotkeysScope({ hotkey: { requireReset: true } })

  formatForDisplay = formatForDisplay
  hotkey: Hotkey = this.initial
  mode: RecorderKeyMode = 'code'
  problem = ''
  sequence: Hotkey = ['[KeyX]', '[KeyY]']
  sequenceProblem = ''
  idle = false
  commitOnEnter = true
  recorder!: AlpineHotkeyRecorder
  sequenceRecorder!: AlpineHotkeyRecorder
  get initial(): Hotkey {
    return 'Alt+[KeyR]'
  }
  constructor(public props: () => { log: (message: string) => void }) {}
  startRecording() {
    this.problem = ''
    this.recorder.startRecording()
  }
  resetShortcut() {
    this.recorder.stopRecording()
    this.hotkey = this.initial
    this.problem = ''
  }
  startSequenceRecording() {
    this.sequenceProblem = ''
    this.sequenceRecorder.startRecording()
  }
  init() {
    this.hotkeysScope.createHotkey(
      () => this.hotkey,
      () => this.props().log('Your recorded shortcut fired'),
      {
        meta: { name: 'Recorded action', group: 'Recording' },
      },
    )
    this.hotkeysScope.createHotkey(
      () => this.sequence,
      () => this.props().log('Your recorded sequence fired'),
      {
        meta: { name: 'Recorded sequence', group: 'Recording' },
      },
    )
    this.recorder = this.hotkeysScope.createHotkeyRecorder(() => ({
      recordBy: this.mode,
      ignoreInputs: false,
      detectConflicts: {
        exclude: (reg) => reg.options.meta?.name === 'Recorded action',
      },
      validate: (_, context) =>
        (context.kind === 'chord' &&
          context.parsedChord.modifiers.length > 0) ||
        'Include a modifier for this action.',
      onReject: ({ message }) => (this.problem = message),
      onRecord: (value) => {
        this.hotkey = value
        this.problem = ''
        this.props().log(`Recorded ${value}`)
      },
      onClear: () => {
        this.hotkey = this.initial
        this.problem = ''
        this.props().log('Restored default shortcut')
      },
      onCancel: () =>
        (this.problem = 'Recording cancelled. Binding unchanged.'),
    }))
    // maxSteps above 1 records sequences; one step records a chord.
    this.sequenceRecorder = this.hotkeysScope.createHotkeyRecorder(() => ({
      maxSteps: Infinity,
      recordBy: this.mode,
      ignoreInputs: false,
      idleTimeoutMs: this.idle ? 1500 : undefined,
      commitKeys: this.commitOnEnter ? 'enter' : 'none',
      detectConflicts: {
        exclude: (reg) => reg.options.meta?.name === 'Recorded sequence',
      },
      validate: (value) =>
        (isHotkeySequence(value) && value.length >= 2) ||
        'Record at least two chords.',
      onRecord: (value) => {
        this.sequence = value
        this.sequenceProblem = ''
        this.props().log(
          `Recorded sequence ${formatForDisplay(value, { sequenceSeparator: ' → ' })}`,
        )
      },
      onReject: (reason) => (this.sequenceProblem = reason.message),
      onClear: () => {
        this.sequence = ['[KeyX]', '[KeyY]']
        this.sequenceProblem = 'Restored default sequence.'
      },
      onCancel: () => (this.sequenceProblem = 'Recording cancelled.'),
    }))
  }
  destroy() {
    this.hotkeysScope.destroy()
  }
}

class Formatting {
  formatForDisplay = formatForDisplay
  platform: 'mac' | 'windows' | 'linux' = 'mac'
  modifierSymbols = false
  keySymbols = true
  get options() {
    return {
      platform: this.platform,
      useSymbols: { modifiers: this.modifierSymbols, keys: this.keySymbols },
    }
  }
}
Alpine.data(
  'hint',
  (
    props: () => {
      hotkey: RegisterableHotkey
      enabled?: boolean
    },
  ) => new Hint(props),
)
Alpine.data('layout', () => new Layout())
Alpine.data(
  'tickets',
  (props: () => { log: (message: string) => void }) => new Tickets(props),
)
Alpine.data(
  'editorPane',
  (props: () => { name: string; log: (message: string) => void }) =>
    new EditorPane(props),
)
Alpine.data(
  'editor',
  (props: () => { log: (message: string) => void }) => new Editor(props),
)
Alpine.data(
  'sequences',
  (props: () => { log: (message: string) => void }) => new Sequences(props),
)
Alpine.data(
  'recording',
  (props: () => { log: (message: string) => void }) => new Recording(props),
)
Alpine.data('formatting', () => new Formatting())
Alpine.start()