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

Angular Example: Scopes

import {
  Component,
  ElementRef,
  Injectable,
  computed,
  inject,
  input,
  output,
  signal,
} from '@angular/core'
import {
  formatForDisplay,
  injectHotkey,
  provideHotkeys,
} from '@tanstack/angular-hotkeys'
import type { OnDestroy, OnInit } from '@angular/core'
import type { Hotkey } from '@tanstack/angular-hotkeys'

// Every hotkey below is a plain `injectHotkey` call with no `scope` option. It
// registers in the scope of the nearest `provideHotkeys` that sets one.

type ExclusiveOption = boolean | { allow: ReadonlyArray<string> }

interface Entry {
  id: number
  scope: string
  message: string
}

@Injectable({ providedIn: 'root' })
export class Activity {
  readonly entries = signal<Array<Entry>>([])
  #nextId = 0

  log(scope: string, message: string) {
    this.entries.update((entries) =>
      [{ id: this.#nextId++, scope, message }, ...entries].slice(0, 20),
    )
  }

  clear() {
    this.entries.set([])
  }
}

@Component({
  selector: 'app-key',
  standalone: true,
  template: `<kbd>{{ label() }}</kbd>`,
})
export class Key {
  readonly hotkey = input.required<Hotkey>()
  readonly label = computed(() => formatForDisplay(this.hotkey()))
}

// ---------------------------------------------------------------------------
// Providers drawn as boxes
// ---------------------------------------------------------------------------

/** The box alone, for content rendered apart from its provider, such as a dialog. */
@Component({
  selector: 'app-scope-frame',
  standalone: true,
  template: `
    <fieldset
      class="scope-frame"
      [attr.data-exclusive]="!!exclusive()"
      [attr.data-enabled]="enabled() !== false"
    >
      <legend>
        <code>{{ label() }}</code>
      </legend>
      <ng-content />
    </fieldset>
  `,
})
export class ScopeFrame {
  readonly scope = input<string>('global')
  readonly exclusive = input<ExclusiveOption | undefined>(undefined)
  readonly enabled = input<boolean | undefined>(undefined)
  readonly label = computed(() => {
    const scope = this.scope()
    const exclusive = this.exclusive()
    const enabled = this.enabled()
    const attributes = [
      scope !== 'global' && `scope: '${scope}'`,
      exclusive &&
        `exclusive: ${exclusive === true ? 'true' : `{ allow: [${exclusive.allow.map((s) => `'${s}'`).join(', ')}] }`}`,
      enabled !== undefined && `enabled: ${enabled}`,
    ].filter(Boolean)
    // The root has no scoped provider; its hotkeys are in the `global` scope.
    return attributes.length > 0
      ? `provideHotkeys({ ${attributes.join(', ')} })`
      : 'Root injector'
  })
}

/**
 * Provides a hotkey scope to the content inside it, with a box around it. The
 * provider's getters run in this component's injection context, so they can
 * read its inputs.
 */
@Component({
  selector: 'app-scope',
  standalone: true,
  imports: [ScopeFrame],
  providers: [
    provideHotkeys({
      scope: () => inject(Scope).scope(),
      exclusive: () => inject(Scope).exclusive(),
      enabled: () => inject(Scope).enabled(),
    }),
  ],
  template: `
    <app-scope-frame
      [scope]="scope()"
      [exclusive]="exclusive()"
      [enabled]="enabled()"
    >
      <ng-content />
    </app-scope-frame>
  `,
})
export class Scope {
  readonly scope = input.required<string>()
  readonly exclusive = input<ExclusiveOption | undefined>(undefined)
  readonly enabled = input<boolean | undefined>(undefined)
}

// ---------------------------------------------------------------------------
// A hotkey in its own scope, so a dialog can allow just this one
// ---------------------------------------------------------------------------

@Component({
  selector: 'app-palette',
  standalone: true,
  imports: [Key],
  template: `
    <section class="demo-section">
      <h2>Command palette</h2>
      <p>
        <app-key hotkey="Mod+K" /> lives in its own <code>palette</code> scope,
        so an exclusive dialog can allow it without allowing everything else.
      </p>
      <div class="counter">
        Opened {{ opened() }} {{ opened() === 1 ? 'time' : 'times' }}
      </div>
    </section>
  `,
})
export class Palette {
  readonly opened = signal(0)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Mod+K', () => {
      this.opened.update((n) => n + 1)
      activity.log('palette', 'Command palette opened')
    })
  }
}

// ---------------------------------------------------------------------------
// Nested scopes beat their ancestors
// ---------------------------------------------------------------------------

@Component({
  selector: 'app-comment-box',
  standalone: true,
  template: `
    <div class="panel">
      <strong>Comments</strong>
      <p>{{ comments() }} posted</p>
    </div>
  `,
})
export class CommentBox {
  readonly comments = signal(0)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Mod+S', () => {
      this.comments.update((n) => n + 1)
      activity.log('comments', 'Comment posted')
    })
  }
}

@Component({
  selector: 'app-nested-demo',
  standalone: true,
  imports: [CommentBox, Key, Scope],
  template: `
    <section class="demo-section">
      <h2>Nested scopes</h2>
      <p>
        The document and its comment box both register
        <app-key hotkey="Mod+S" />. The comment box is nested, so it wins while
        it is mounted. Hide it and the document handles the key again.
      </p>
      <label>
        <input
          type="checkbox"
          [checked]="showComments()"
          (change)="showComments.set($any($event.target).checked)"
        />
        Show comment box
      </label>
      @if (showComments()) {
        <app-scope scope="comments"><app-comment-box /></app-scope>
      }
    </section>
  `,
})
export class NestedDemo {
  readonly showComments = signal(true)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Mod+S', () => activity.log('document', 'Document saved'))
  }
}

// ---------------------------------------------------------------------------
// enabled turns a scope off without destroying it
// ---------------------------------------------------------------------------

@Component({
  selector: 'app-folder-pane',
  standalone: true,
  template: `
    <button
      class="panel folder"
      [attr.data-current]="current()"
      (click)="selected.emit()"
    >
      <strong>{{ name() }}</strong>
      <span>{{ items() }} items</span>
    </button>
  `,
})
export class FolderPane {
  readonly name = input.required<string>()
  readonly current = input(false)
  readonly selected = output()
  readonly items = signal(0)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Alt+[KeyN]', () => {
      this.items.update((n) => n + 1)
      activity.log(this.name().toLowerCase(), `Added to ${this.name()}`)
    })
  }
}

@Component({
  selector: 'app-tabs-demo',
  standalone: true,
  imports: [FolderPane, Key, Scope],
  template: `
    <section class="demo-section">
      <h2>Enabled scopes</h2>
      <p>
        All three folders stay in the page and register
        <app-key hotkey="Alt+N" />. Only the selected folder's provider is
        <code>enabled</code>, so the key adds to that one. Switch with
        <app-key hotkey="Alt+1" />, <app-key hotkey="Alt+2" />, and
        <app-key hotkey="Alt+3" />.
      </p>
      <div class="folders">
        @for (name of folders; track name) {
          <app-scope
            [scope]="name.toLowerCase()"
            [enabled]="current() === name"
          >
            <app-folder-pane
              [name]="name"
              [current]="current() === name"
              (selected)="current.set(name)"
            />
          </app-scope>
        }
      </div>
    </section>
  `,
})
export class TabsDemo {
  readonly folders = ['Inbox', 'Archive', 'Spam'] as const
  readonly current = signal<(typeof this.folders)[number]>('Inbox')

  constructor() {
    injectHotkey('Alt+[Digit1]', () => this.current.set('Inbox'))
    injectHotkey('Alt+[Digit2]', () => this.current.set('Archive'))
    injectHotkey('Alt+[Digit3]', () => this.current.set('Spam'))
  }
}

// ---------------------------------------------------------------------------
// exclusive suspends every other scope, except the ones it allows
// ---------------------------------------------------------------------------

/** Nested in the exclusive scope, so it stays active without being allowed. */
@Component({
  selector: 'app-steps',
  standalone: true,
  imports: [Key],
  template: `
    <div class="panel">
      Step {{ step() }} of 3. <app-key hotkey="Alt+N" /> moves to the next step.
      It is in the nested <code>dialog-steps</code> scope, so it still works,
      and it takes the key from the suspended folders.
    </div>
  `,
})
export class Steps {
  readonly step = signal(1)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Alt+[KeyN]', () => {
      this.step.update((n) => (n % 3) + 1)
      activity.log('dialog-steps', 'Next step')
    })
  }
}

/**
 * The dialog provides its own exclusive scope. Its element moves to the body,
 * outside the boxes above; the scope still comes from the injector tree, not
 * from where it renders.
 */
@Component({
  selector: 'app-exclusive-dialog',
  standalone: true,
  imports: [Key, Scope, ScopeFrame, Steps],
  providers: [
    provideHotkeys({
      scope: 'dialog',
      exclusive: () => inject(ExclusiveDialog).exclusive(),
    }),
  ],
  template: `
    <div class="backdrop" (click)="closed.emit()">
      <div
        class="dialog"
        role="dialog"
        aria-modal="true"
        aria-label="Exclusive dialog"
        (click)="$event.stopPropagation()"
      >
        <app-scope-frame scope="dialog" [exclusive]="exclusive()">
          <h2>Exclusive dialog</h2>
          <p>
            <app-key hotkey="Mod+S" /> saves this dialog instead of the
            document. <app-key hotkey="Escape" /> closes it. The folder hotkeys
            are suspended, and so is anything you did not allow above.
          </p>
          <app-scope scope="dialog-steps"><app-steps /></app-scope>
          <div class="actions">
            <button class="secondary" (click)="closed.emit()">Cancel</button>
            <button (click)="saved.emit()">Save</button>
          </div>
        </app-scope-frame>
      </div>
    </div>
  `,
})
export class ExclusiveDialog implements OnInit, OnDestroy {
  readonly exclusive = input.required<ExclusiveOption>()
  readonly closed = output()
  readonly saved = output()
  readonly #element = inject(ElementRef<HTMLElement>)

  constructor() {
    injectHotkey('Escape', () => this.closed.emit())
    injectHotkey('Mod+S', () => this.saved.emit())
  }

  ngOnInit() {
    document.body.append(this.#element.nativeElement)
  }

  ngOnDestroy() {
    this.#element.nativeElement.remove()
  }
}

@Component({
  selector: 'app-dialog-demo',
  standalone: true,
  imports: [ExclusiveDialog, Key],
  template: `
    <section class="demo-section">
      <h2>Exclusive scopes</h2>
      <p>
        An exclusive provider suspends every other scope while it exists,
        including <code>global</code>. Scopes nested inside it keep working.
        List scopes in <code>allow</code> to keep them working too.
      </p>
      <fieldset>
        <legend>Allow while the dialog is open</legend>
        @for (scope of allowOptions; track scope) {
          <label>
            <input
              type="checkbox"
              [checked]="allow().includes(scope)"
              (change)="toggle(scope, $any($event.target).checked)"
            />
            <code>{{ scope }}</code>
          </label>
        }
      </fieldset>
      <button (click)="open.set(true)">
        Open dialog <app-key hotkey="Alt+D" />
      </button>
      @if (open()) {
        <app-exclusive-dialog
          [exclusive]="exclusive()"
          (closed)="open.set(false)"
          (saved)="save()"
        />
      }
    </section>
  `,
})
export class DialogDemo {
  readonly allowOptions = ['global', 'palette'] as const
  readonly open = signal(false)
  readonly allow = signal<Array<string>>(['palette'])
  readonly exclusive = computed<ExclusiveOption>(() =>
    this.allow().length > 0 ? { allow: this.allow() } : true,
  )
  readonly #activity = inject(Activity)

  constructor() {
    injectHotkey('Alt+[KeyD]', () => this.open.set(true))
  }

  toggle(scope: string, checked: boolean) {
    this.allow.update((allow) =>
      checked ? [...allow, scope] : allow.filter((s) => s !== scope),
    )
  }

  save() {
    this.#activity.log('dialog', 'Dialog saved')
    this.open.set(false)
  }
}

// ---------------------------------------------------------------------------
// Activity log
// ---------------------------------------------------------------------------

/** Registered with no scoped provider above it, so it is in the `global` scope. */
@Component({
  selector: 'app-activity-log',
  standalone: true,
  imports: [Key],
  template: `
    <section class="demo-section">
      <h2>Activity</h2>
      <p>
        <app-key hotkey="Alt+L" /> clears the log. No scoped provider is above
        it, so it is in the <code>global</code> scope.
      </p>
      <div class="log">
        @for (entry of activity.entries(); track entry.id) {
          <div class="log-entry">
            <code class="log-scope">{{ entry.scope }}</code>
            {{ entry.message }}
          </div>
        } @empty {
          <div class="hint">Press a hotkey above.</div>
        }
      </div>
    </section>
  `,
})
export class ActivityLog {
  readonly activity = inject(Activity)

  constructor() {
    injectHotkey('Alt+[KeyL]', () => this.activity.clear())
  }
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [
    ActivityLog,
    DialogDemo,
    NestedDemo,
    Palette,
    Scope,
    ScopeFrame,
    TabsDemo,
  ],
  template: `
    <div class="app">
      <header>
        <h1>Hotkey scopes</h1>
        <p>
          Each <code>provideHotkeys</code> with a <code>scope</code> in a
          component's <code>providers</code> is a scope. Its hotkeys fire while
          the component exists and is enabled. Open the hotkeys devtools to see
          which scopes are active, suspended, or allowed.
        </p>
      </header>
      <app-scope-frame>
        <app-scope scope="palette"><app-palette /></app-scope>
        <app-scope scope="document"><app-nested-demo /></app-scope>
        <app-scope scope="mail"><app-tabs-demo /></app-scope>
        <app-scope scope="settings"><app-dialog-demo /></app-scope>
        <app-activity-log />
      </app-scope-frame>
    </div>
  `,
})
export class AppComponent {}
import {
  Component,
  ElementRef,
  Injectable,
  computed,
  inject,
  input,
  output,
  signal,
} from '@angular/core'
import {
  formatForDisplay,
  injectHotkey,
  provideHotkeys,
} from '@tanstack/angular-hotkeys'
import type { OnDestroy, OnInit } from '@angular/core'
import type { Hotkey } from '@tanstack/angular-hotkeys'

// Every hotkey below is a plain `injectHotkey` call with no `scope` option. It
// registers in the scope of the nearest `provideHotkeys` that sets one.

type ExclusiveOption = boolean | { allow: ReadonlyArray<string> }

interface Entry {
  id: number
  scope: string
  message: string
}

@Injectable({ providedIn: 'root' })
export class Activity {
  readonly entries = signal<Array<Entry>>([])
  #nextId = 0

  log(scope: string, message: string) {
    this.entries.update((entries) =>
      [{ id: this.#nextId++, scope, message }, ...entries].slice(0, 20),
    )
  }

  clear() {
    this.entries.set([])
  }
}

@Component({
  selector: 'app-key',
  standalone: true,
  template: `<kbd>{{ label() }}</kbd>`,
})
export class Key {
  readonly hotkey = input.required<Hotkey>()
  readonly label = computed(() => formatForDisplay(this.hotkey()))
}

// ---------------------------------------------------------------------------
// Providers drawn as boxes
// ---------------------------------------------------------------------------

/** The box alone, for content rendered apart from its provider, such as a dialog. */
@Component({
  selector: 'app-scope-frame',
  standalone: true,
  template: `
    <fieldset
      class="scope-frame"
      [attr.data-exclusive]="!!exclusive()"
      [attr.data-enabled]="enabled() !== false"
    >
      <legend>
        <code>{{ label() }}</code>
      </legend>
      <ng-content />
    </fieldset>
  `,
})
export class ScopeFrame {
  readonly scope = input<string>('global')
  readonly exclusive = input<ExclusiveOption | undefined>(undefined)
  readonly enabled = input<boolean | undefined>(undefined)
  readonly label = computed(() => {
    const scope = this.scope()
    const exclusive = this.exclusive()
    const enabled = this.enabled()
    const attributes = [
      scope !== 'global' && `scope: '${scope}'`,
      exclusive &&
        `exclusive: ${exclusive === true ? 'true' : `{ allow: [${exclusive.allow.map((s) => `'${s}'`).join(', ')}] }`}`,
      enabled !== undefined && `enabled: ${enabled}`,
    ].filter(Boolean)
    // The root has no scoped provider; its hotkeys are in the `global` scope.
    return attributes.length > 0
      ? `provideHotkeys({ ${attributes.join(', ')} })`
      : 'Root injector'
  })
}

/**
 * Provides a hotkey scope to the content inside it, with a box around it. The
 * provider's getters run in this component's injection context, so they can
 * read its inputs.
 */
@Component({
  selector: 'app-scope',
  standalone: true,
  imports: [ScopeFrame],
  providers: [
    provideHotkeys({
      scope: () => inject(Scope).scope(),
      exclusive: () => inject(Scope).exclusive(),
      enabled: () => inject(Scope).enabled(),
    }),
  ],
  template: `
    <app-scope-frame
      [scope]="scope()"
      [exclusive]="exclusive()"
      [enabled]="enabled()"
    >
      <ng-content />
    </app-scope-frame>
  `,
})
export class Scope {
  readonly scope = input.required<string>()
  readonly exclusive = input<ExclusiveOption | undefined>(undefined)
  readonly enabled = input<boolean | undefined>(undefined)
}

// ---------------------------------------------------------------------------
// A hotkey in its own scope, so a dialog can allow just this one
// ---------------------------------------------------------------------------

@Component({
  selector: 'app-palette',
  standalone: true,
  imports: [Key],
  template: `
    <section class="demo-section">
      <h2>Command palette</h2>
      <p>
        <app-key hotkey="Mod+K" /> lives in its own <code>palette</code> scope,
        so an exclusive dialog can allow it without allowing everything else.
      </p>
      <div class="counter">
        Opened {{ opened() }} {{ opened() === 1 ? 'time' : 'times' }}
      </div>
    </section>
  `,
})
export class Palette {
  readonly opened = signal(0)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Mod+K', () => {
      this.opened.update((n) => n + 1)
      activity.log('palette', 'Command palette opened')
    })
  }
}

// ---------------------------------------------------------------------------
// Nested scopes beat their ancestors
// ---------------------------------------------------------------------------

@Component({
  selector: 'app-comment-box',
  standalone: true,
  template: `
    <div class="panel">
      <strong>Comments</strong>
      <p>{{ comments() }} posted</p>
    </div>
  `,
})
export class CommentBox {
  readonly comments = signal(0)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Mod+S', () => {
      this.comments.update((n) => n + 1)
      activity.log('comments', 'Comment posted')
    })
  }
}

@Component({
  selector: 'app-nested-demo',
  standalone: true,
  imports: [CommentBox, Key, Scope],
  template: `
    <section class="demo-section">
      <h2>Nested scopes</h2>
      <p>
        The document and its comment box both register
        <app-key hotkey="Mod+S" />. The comment box is nested, so it wins while
        it is mounted. Hide it and the document handles the key again.
      </p>
      <label>
        <input
          type="checkbox"
          [checked]="showComments()"
          (change)="showComments.set($any($event.target).checked)"
        />
        Show comment box
      </label>
      @if (showComments()) {
        <app-scope scope="comments"><app-comment-box /></app-scope>
      }
    </section>
  `,
})
export class NestedDemo {
  readonly showComments = signal(true)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Mod+S', () => activity.log('document', 'Document saved'))
  }
}

// ---------------------------------------------------------------------------
// enabled turns a scope off without destroying it
// ---------------------------------------------------------------------------

@Component({
  selector: 'app-folder-pane',
  standalone: true,
  template: `
    <button
      class="panel folder"
      [attr.data-current]="current()"
      (click)="selected.emit()"
    >
      <strong>{{ name() }}</strong>
      <span>{{ items() }} items</span>
    </button>
  `,
})
export class FolderPane {
  readonly name = input.required<string>()
  readonly current = input(false)
  readonly selected = output()
  readonly items = signal(0)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Alt+[KeyN]', () => {
      this.items.update((n) => n + 1)
      activity.log(this.name().toLowerCase(), `Added to ${this.name()}`)
    })
  }
}

@Component({
  selector: 'app-tabs-demo',
  standalone: true,
  imports: [FolderPane, Key, Scope],
  template: `
    <section class="demo-section">
      <h2>Enabled scopes</h2>
      <p>
        All three folders stay in the page and register
        <app-key hotkey="Alt+N" />. Only the selected folder's provider is
        <code>enabled</code>, so the key adds to that one. Switch with
        <app-key hotkey="Alt+1" />, <app-key hotkey="Alt+2" />, and
        <app-key hotkey="Alt+3" />.
      </p>
      <div class="folders">
        @for (name of folders; track name) {
          <app-scope
            [scope]="name.toLowerCase()"
            [enabled]="current() === name"
          >
            <app-folder-pane
              [name]="name"
              [current]="current() === name"
              (selected)="current.set(name)"
            />
          </app-scope>
        }
      </div>
    </section>
  `,
})
export class TabsDemo {
  readonly folders = ['Inbox', 'Archive', 'Spam'] as const
  readonly current = signal<(typeof this.folders)[number]>('Inbox')

  constructor() {
    injectHotkey('Alt+[Digit1]', () => this.current.set('Inbox'))
    injectHotkey('Alt+[Digit2]', () => this.current.set('Archive'))
    injectHotkey('Alt+[Digit3]', () => this.current.set('Spam'))
  }
}

// ---------------------------------------------------------------------------
// exclusive suspends every other scope, except the ones it allows
// ---------------------------------------------------------------------------

/** Nested in the exclusive scope, so it stays active without being allowed. */
@Component({
  selector: 'app-steps',
  standalone: true,
  imports: [Key],
  template: `
    <div class="panel">
      Step {{ step() }} of 3. <app-key hotkey="Alt+N" /> moves to the next step.
      It is in the nested <code>dialog-steps</code> scope, so it still works,
      and it takes the key from the suspended folders.
    </div>
  `,
})
export class Steps {
  readonly step = signal(1)

  constructor() {
    const activity = inject(Activity)
    injectHotkey('Alt+[KeyN]', () => {
      this.step.update((n) => (n % 3) + 1)
      activity.log('dialog-steps', 'Next step')
    })
  }
}

/**
 * The dialog provides its own exclusive scope. Its element moves to the body,
 * outside the boxes above; the scope still comes from the injector tree, not
 * from where it renders.
 */
@Component({
  selector: 'app-exclusive-dialog',
  standalone: true,
  imports: [Key, Scope, ScopeFrame, Steps],
  providers: [
    provideHotkeys({
      scope: 'dialog',
      exclusive: () => inject(ExclusiveDialog).exclusive(),
    }),
  ],
  template: `
    <div class="backdrop" (click)="closed.emit()">
      <div
        class="dialog"
        role="dialog"
        aria-modal="true"
        aria-label="Exclusive dialog"
        (click)="$event.stopPropagation()"
      >
        <app-scope-frame scope="dialog" [exclusive]="exclusive()">
          <h2>Exclusive dialog</h2>
          <p>
            <app-key hotkey="Mod+S" /> saves this dialog instead of the
            document. <app-key hotkey="Escape" /> closes it. The folder hotkeys
            are suspended, and so is anything you did not allow above.
          </p>
          <app-scope scope="dialog-steps"><app-steps /></app-scope>
          <div class="actions">
            <button class="secondary" (click)="closed.emit()">Cancel</button>
            <button (click)="saved.emit()">Save</button>
          </div>
        </app-scope-frame>
      </div>
    </div>
  `,
})
export class ExclusiveDialog implements OnInit, OnDestroy {
  readonly exclusive = input.required<ExclusiveOption>()
  readonly closed = output()
  readonly saved = output()
  readonly #element = inject(ElementRef<HTMLElement>)

  constructor() {
    injectHotkey('Escape', () => this.closed.emit())
    injectHotkey('Mod+S', () => this.saved.emit())
  }

  ngOnInit() {
    document.body.append(this.#element.nativeElement)
  }

  ngOnDestroy() {
    this.#element.nativeElement.remove()
  }
}

@Component({
  selector: 'app-dialog-demo',
  standalone: true,
  imports: [ExclusiveDialog, Key],
  template: `
    <section class="demo-section">
      <h2>Exclusive scopes</h2>
      <p>
        An exclusive provider suspends every other scope while it exists,
        including <code>global</code>. Scopes nested inside it keep working.
        List scopes in <code>allow</code> to keep them working too.
      </p>
      <fieldset>
        <legend>Allow while the dialog is open</legend>
        @for (scope of allowOptions; track scope) {
          <label>
            <input
              type="checkbox"
              [checked]="allow().includes(scope)"
              (change)="toggle(scope, $any($event.target).checked)"
            />
            <code>{{ scope }}</code>
          </label>
        }
      </fieldset>
      <button (click)="open.set(true)">
        Open dialog <app-key hotkey="Alt+D" />
      </button>
      @if (open()) {
        <app-exclusive-dialog
          [exclusive]="exclusive()"
          (closed)="open.set(false)"
          (saved)="save()"
        />
      }
    </section>
  `,
})
export class DialogDemo {
  readonly allowOptions = ['global', 'palette'] as const
  readonly open = signal(false)
  readonly allow = signal<Array<string>>(['palette'])
  readonly exclusive = computed<ExclusiveOption>(() =>
    this.allow().length > 0 ? { allow: this.allow() } : true,
  )
  readonly #activity = inject(Activity)

  constructor() {
    injectHotkey('Alt+[KeyD]', () => this.open.set(true))
  }

  toggle(scope: string, checked: boolean) {
    this.allow.update((allow) =>
      checked ? [...allow, scope] : allow.filter((s) => s !== scope),
    )
  }

  save() {
    this.#activity.log('dialog', 'Dialog saved')
    this.open.set(false)
  }
}

// ---------------------------------------------------------------------------
// Activity log
// ---------------------------------------------------------------------------

/** Registered with no scoped provider above it, so it is in the `global` scope. */
@Component({
  selector: 'app-activity-log',
  standalone: true,
  imports: [Key],
  template: `
    <section class="demo-section">
      <h2>Activity</h2>
      <p>
        <app-key hotkey="Alt+L" /> clears the log. No scoped provider is above
        it, so it is in the <code>global</code> scope.
      </p>
      <div class="log">
        @for (entry of activity.entries(); track entry.id) {
          <div class="log-entry">
            <code class="log-scope">{{ entry.scope }}</code>
            {{ entry.message }}
          </div>
        } @empty {
          <div class="hint">Press a hotkey above.</div>
        }
      </div>
    </section>
  `,
})
export class ActivityLog {
  readonly activity = inject(Activity)

  constructor() {
    injectHotkey('Alt+[KeyL]', () => this.activity.clear())
  }
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [
    ActivityLog,
    DialogDemo,
    NestedDemo,
    Palette,
    Scope,
    ScopeFrame,
    TabsDemo,
  ],
  template: `
    <div class="app">
      <header>
        <h1>Hotkey scopes</h1>
        <p>
          Each <code>provideHotkeys</code> with a <code>scope</code> in a
          component's <code>providers</code> is a scope. Its hotkeys fire while
          the component exists and is enabled. Open the hotkeys devtools to see
          which scopes are active, suspended, or allowed.
        </p>
      </header>
      <app-scope-frame>
        <app-scope scope="palette"><app-palette /></app-scope>
        <app-scope scope="document"><app-nested-demo /></app-scope>
        <app-scope scope="mail"><app-tabs-demo /></app-scope>
        <app-scope scope="settings"><app-dialog-demo /></app-scope>
        <app-activity-log />
      </app-scope-frame>
    </div>
  `,
})
export class AppComponent {}