In Angular, how are key-filtered event bindings like (keydown.enter) and (keydown.code.alt.keys) matched, and why does (keydown.enter) ignore Shift+Enter?
answer
- a pseudo-event parsed by a plugin
- modifier set must match exactly
- alt, control, meta, shift
- code. suffix reads the physical key
basics
~20 sAngular's key-events plugin parses the name into a key plus modifiers and runs the handler only when the pressed key and the full set of held modifiers match exactly, so Shift+Enter does not match keydown.enter.
solid answer
~40 s`keydown.enter` is not a real DOM event. Angular's `KeyEventsPlugin` claims names that start with `keydown` or `keyup`, listens to the base event, and filters it. It builds a string from the event, the held modifiers in the fixed order `alt`, `control`, `meta`, `shift`, then the lower-cased key, and runs the handler only when that string equals the one parsed from the binding. Because the comparison is exact, an extra held modifier fails the match: Shift+Enter produces `shift.enter`, which is not `enter`. By default the key comes from `event.key` with a few aliases (`space`, `dot`, `esc` for `escape`). Adding `code.` switches to `event.code`, the physical key, which is how you match Alt shortcuts on macOS where Alt changes the character.
code
ts · 27 linesimport { Component, signal } from '@angular/core';
@Component({
selector: 'app-chat-input',
template: `
<textarea
[value]="draft()"
#box
(input)="draft.set(box.value)"
(keydown.enter)="send($event)"
(keydown.shift.enter)="newline()"
></textarea>
`,
})
export class ChatInput {
draft = signal('');
send(event: Event) {
event.preventDefault(); // keep Enter from adding a newline
console.log('send', this.draft());
this.draft.set('');
}
newline() {
// default action inserts the newline; nothing to do here
}
}go deeper
Recall the syntax (keydown.enter), the four modifier words, and that Shift+Enter needs its own keydown.shift.enter binding.
Explain the exact-match comparison, the fixed modifier order, the key aliases, and when the code. prefix is needed.
Show you have debugged layout-dependent shortcuts: macOS Alt characters, non-US layouts, and bindings that silently never match.
Frame shortcut conventions for a design system: when to follow characters versus physical keys, and how to document them per platform.
## Key filters are pseudo-events The browser only fires `keydown` and `keyup`. A binding such as `(keydown.enter)` or `(keyup.shift.tab)` is a **pseudo-event**: a name Angular's event system understands and turns into a filtered listener on the real event. The work is done by `KeyEventsPlugin` in `@angular/platform-browser`, one of the plugins registered under `EVENT_MANAGER_PLUGINS`. ## How the binding name is parsed When the template binds `(keydown.shift.enter)`, the plugin's `parseEventName` does this: 1. Lower-cases the whole name and splits it on `.`. 2. Takes the first part as the DOM event. It must be `keydown` or `keyup`, or the plugin declines the name. 3. Takes the **last** part as the key, normalising `esc` to `escape`. 4. Removes an optional `code` part and remembers that `event.code` should be used. 5. Removes the recognised modifiers, **only** `alt`, `control`, `meta` and `shift`, and writes them in that fixed order. 6. If any unrecognised part is left over, it returns `null` and does not handle the name. The result is a normalised key string such as `shift.enter` or `code.alt.keys`. Because the modifiers are sorted, `(keydown.shift.control.k)` and `(keydown.control.shift.k)` mean the same thing. ## How an event is matched For every real `keydown`, the plugin builds the same kind of string from the event: - The key is `event.key` (or `event.code` with the `code.` prefix), lower-cased, with a few aliases: a space becomes `space`, `.` becomes `dot` because the dot is the separator, and legacy values like `Esc` or `Left` map to `Escape` and `ArrowLeft`. - Every modifier whose flag is set (`altKey`, `ctrlKey`, `metaKey`, `shiftKey`) is added, in the same fixed order. - The handler runs only when the two strings are **equal**. That equality is the whole story behind the Shift+Enter question: | Binding | User presses | Built from event | Handler runs? | |---|---|---|---| | `keydown.enter` | Enter | `enter` | yes | | `keydown.enter` | Shift+Enter | `shift.enter` | no | | `keydown.shift.enter` | Shift+Enter | `shift.enter` | yes | | `keydown.control.s` | Ctrl+Shift+S | `control.shift.s` | no | The filter is not "Enter, whatever else is held"; it is "exactly this key with exactly these modifiers". That is usually what you want (a chat box sends on Enter and inserts a newline on Shift+Enter with two bindings), but it surprises people who expect a subset match. ## Key versus code filters By default the filter compares against `event.key`, the character or named key the keyboard layout produced. The docs point out the trap this causes on macOS: holding Alt changes the character, so Alt+S reports a key of `ß`, and `(keydown.alt.s)` never matches. Adding `code.` compares against `event.code` instead, the physical key position, which Angular compares in lower case (`keys`, `digit1`, `enter`): ```html <!-- Alt+S by physical key, works on macOS --> <textarea (keydown.code.alt.keys)="summarise()"></textarea> ``` The matching is case-insensitive because both sides are lower-cased, so `code.KeyS` and `code.keys` are equivalent. Choosing between key and code is a layout question (a shortcut named after a letter should follow the letter; a shortcut named after a position should follow the position); the difference between the two properties themselves is browser-platform material. ## A side benefit: unmatched keys stay cheap `KeyEventsPlugin` attaches its native listener outside the Angular zone (`runOutsideAngular`) and only re-enters it (`runGuarded`) when the key string matches. In an app that still uses zone.js, that means the `(keydown.enter)` binding itself does not trigger an application-wide change detection pass for every ordinary keystroke; only the matching Enter does. A plain `(keydown)` binding that checks `event.key` inside the method gives up that benefit, because Angular's listener runs, and marks the view dirty, on every key. The same holds in a zoneless app: Angular's listener wrapper, which is what marks the view and notifies the scheduler, runs only on a match for the filtered binding but on every key for the plain one. ## Practical consequences - **Only four modifier words exist.** `ctrl`, `cmd` and `option` are not recognised, and an unrecognised part makes the plugin decline the name entirely. - **Enter, Tab, Escape and arrows use their `key` names**: `enter`, `tab`, `escape` (or `esc`), `arrowdown`. - **The dot key** is written `dot`, the space bar `space`. - **The handler still receives the full event**, so `$event.preventDefault()` works as usual. - **Only `keydown` and `keyup` are supported.** `keypress` is not a base event the plugin accepts.
- Why does (keydown.alt.s) never fire for macOS users in an Angular app, and how do you fix it?On macOS, Alt changes the produced character, so Alt+S reports `event.key` as `ß` and the key-based filter never equals `alt.s`. Switch to a code filter, `(keydown.code.alt.keys)`, which compares against `event.code`, the physical key, and is unaffected by the character the layout produces.
- How do you bind the dot or space key with an Angular key filter?Use the aliases the plugin builds from the event: `(keydown.space)` for the space bar and `(keydown.dot)` for the full stop, because a literal `.` would be read as a separator in the event name. Escape can be written `escape` or `esc`.
saying these in an interview costs you the question
- (keydown.enter) fires whenever Enter is pressed, whatever modifiers are held
- keydown.enter is a native DOM event the browser fires
- Modifier order in the binding name changes what matches
- (keydown.alt.s) works the same on every operating system
- Key filters are case-sensitive, so KeyS and keys differ