Preventing Esc from closing dialogs during IME composition
One user of my script ReviewTool on Chinese Wikipedia told me they pressed Esc to cancel IME composition, but the dialog closed instead.
Hmmm. Do people actually use Esc to cancel IME composition? As a Mandarin speaker, I have never tried that before.
...Anyway, to me this is less a code bug and more a user experience issue. In many dialog systems, Esc means "close." But now we know in CJK IMEs, Esc also means "cancel the current candidate/composition." So when someone is composing with IME, they expect Esc to cancel composition, not close the dialog.
In this post, I will share the solution I ended up using after testing a few approaches.
Intuition
Here is the intuition of this solution:
Install listeners once when dialogs mount, and remove them when dialogs unmount. Track state from compositionstart/compositionend, and keep a short delay right after composition ends. Also keep a recent timestamp from composition-related beforeinput/input.
On Esc keydown and keyup, block close behavior when focus is editable and composition still looks active/recent. Also guard native cancel so Esc does not slip past key handlers.
Implementation
First, let's start with tracking composing and blocking Esc during composition:
let composing = false;
/** Mark IME composition as active. */
function onCompositionStart() {
composing = true;
}
/** Mark IME composition as inactive. */
function onCompositionEnd() {
composing = false;
}
/**
* Block Esc while IME composition is active.
* @param event Keyboard event fired by the browser.
*/
function onEsc(event: KeyboardEvent) {
if (event.key !== "Escape") return;
if (event.isComposing || composing) {
// Stop both default behavior and dialog-level Esc handlers.
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
}
}
// Capture phase lets this run before most UI library handlers.
window.addEventListener("compositionstart", onCompositionStart, true);
window.addEventListener("compositionend", onCompositionEnd, true);
window.addEventListener("keydown", onEsc, true);
That solves some cases, but not all. Next, we can listen to keyup as well, and add a short delayed reset after compositionend to handle event timing differences:
let composing = false;
let resetTimer: number | null = null;
/** Mark composition as active and clear pending reset. */
function onCompositionStart() {
if (resetTimer !== null) window.clearTimeout(resetTimer);
resetTimer = null;
composing = true;
}
/** Delay composition end to handle browser/IME timing differences. */
function onCompositionEnd() {
if (resetTimer !== null) window.clearTimeout(resetTimer);
resetTimer = window.setTimeout(() => {
composing = false;
resetTimer = null;
}, 80);
}
/**
* Block Esc while IME composition is active.
* @param event Keyboard event fired by the browser.
*/
function onEsc(event: KeyboardEvent) {
if (event.key !== "Escape") return;
if (event.isComposing || composing) {
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
}
}
// Listen on both keydown/keyup because some dialog stacks close on keyup.
window.addEventListener("compositionstart", onCompositionStart, true);
window.addEventListener("compositionend", onCompositionEnd, true);
window.addEventListener("keydown", onEsc, true);
window.addEventListener("keyup", onEsc, true);
At this point, the code already works in many cases, just not all the time. The weak spot is event timing: composition and key events do not always arrive in the same order across browsers and IMEs. So besides composing, we also keep a small "recent activity" signal with timestamps:
let composing = false;
let lastCompositionAt = 0;
/** Save the latest composition activity timestamp. */
function markComposition() {
lastCompositionAt = Date.now();
}
/**
* Return true when composition is active or was active very recently.
* @returns Whether Esc should still be treated as IME-related.
*/
function isCompositionLikelyActive() {
// 500ms is practical; tune based on your browser/IME test results.
return composing || (Date.now() - lastCompositionAt <= 500);
}
Then wire it into events step by step. First, update timestamp on composition start/end:
/** Start composition state and update recent timestamp. */
function onCompositionStart() {
composing = true;
markComposition();
}
/** Update recent timestamp when composition ends. */
function onCompositionEnd() {
markComposition();
// ...
}
Then update timestamp from beforeinput/input as another source of composition activity:
/**
* Track composition-like activity from Input Events API.
* @param event Input event from beforeinput/input.
*/
function onCompositionInput(event: InputEvent) {
const inputType = typeof event.inputType === "string" ? event.inputType : "";
if (event.isComposing || inputType.startsWith("insertComposition")) {
composing = true;
markComposition();
}
}
// These events help when key/composition event ordering differs by browser.
window.addEventListener("beforeinput", onCompositionInput, true);
window.addEventListener("input", onCompositionInput, true);
This next part is easy to miss, but it is actually pretty important. This is because some dialogs can also get closed through native <dialog> cancel events, not only by Esc key handlers.
So if you only guard keydown/keyup, you can still miss some cases and end up in the same back-and-forth debugging loop. Adding a native cancel guard closes that gap:
/**
* Prevent native <dialog> cancel while IME composition is active/recent.
* @param event Cancel event dispatched by HTMLDialogElement.
*/
function onDialogCancel(event: Event) {
const target = event.target as HTMLElement | null;
if (!target || target.tagName !== "DIALOG") return;
const activeEditable = isEditable(document.activeElement);
if (activeEditable && isCompositionLikelyActive()) {
// Cancel native Esc-close path.
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
}
}
// Capture so this runs before framework-level dialog listeners.
window.addEventListener("cancel", onDialogCancel, true);
That's it. If you are working on similar dialog issues, I hope this article can save you some time.
The final code ended up around 100 lines, which is not too bad considering all the edge cases it handles. The key to how I got this fix working without too much back-and-forth is because in the very beginning, I have written the dialog codes in one shared utility module, that is imported by all dialogs. So I only need to add listeners and logic in one place, and I can test it across all dialogs immediately. If the code was scattered across different dialog components, I would have had to add listeners in multiple places, and testing would be more tedious.
So, if you are building front-end code that mounts dialogs, try to centralize the mounting logic as much as possible. It will save you a lot of time when you need to make cross-cutting changes like this later on.