diff --git a/src/hooks/use-prompt-input.test.ts b/src/hooks/use-prompt-input.test.ts index 51dfb4db..dd3b62cc 100644 --- a/src/hooks/use-prompt-input.test.ts +++ b/src/hooks/use-prompt-input.test.ts @@ -293,11 +293,38 @@ describe("usePromptInput", () => { const view = mountPrompt(); try { view.type("@a"); - view.press("Escape", { isComposing: true }); + expect(view.press("Escape", { isComposing: true, keyCode: 27 })).toBe( + true, + ); + expect(seen).toEqual([]); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); view.press("Escape"); expect(view.prompt.suggestions).toEqual([]); view.press("Escape"); - expect(seen).toEqual([false, true, false]); + expect(seen).toEqual([true, false]); + } finally { + view.unmount(); + document.removeEventListener("keydown", layer, { capture: true }); + } + }); + + test("Safari's composition-ending Escape (keyCode 229) is left to the IME", () => { + const seen: boolean[] = []; + const layer = (event: KeyboardEvent) => { + if (event.key === "Escape") seen.push(event.defaultPrevented); + }; + document.addEventListener("keydown", layer, { capture: true }); + const view = mountPrompt(); + try { + view.type("@a"); + expect(view.press("Escape", { isComposing: false, keyCode: 229 })).toBe( + true, + ); + expect(seen).toEqual([]); + expect(view.prompt.activeSuggestion?.id).toBe("ada"); + view.press("Escape"); + expect(view.prompt.suggestions).toEqual([]); + expect(seen).toEqual([true]); } finally { view.unmount(); document.removeEventListener("keydown", layer, { capture: true }); diff --git a/src/hooks/use-prompt-input.ts b/src/hooks/use-prompt-input.ts index 10039ef7..66c7e964 100644 --- a/src/hooks/use-prompt-input.ts +++ b/src/hooks/use-prompt-input.ts @@ -153,21 +153,23 @@ export function usePromptInput({ // A Radix dialog or popover around the prompt dismisses on an Escape it // sees in the document's capture phase, before React's handlers run, // unless the event is already marked handled. While the list is open, mark - // it first from the window's capture phase; `onKeyDown` still closes the - // list, so propagation is left alone. + // a normal Escape from the window's capture phase and let it propagate so + // `onKeyDown` can close the list. Stop a composing Escape instead, without + // preventDefault, so the dialog never sees it. useEffect(() => { const element = textareaRef.current; if (!listOpen || element === null) return; const view = element.ownerDocument.defaultView; if (view === null) return; const claimEscape = (event: KeyboardEvent) => { - if ( - event.key === "Escape" && - event.target === element && - !event.isComposing - ) { - event.preventDefault(); + if (event.key !== "Escape" || event.target !== element) return; + // 229 is what Safari reports for the key that ends a composition, + // after `isComposing` has already turned false. + if (event.isComposing || event.keyCode === 229) { + event.stopPropagation(); + return; } + event.preventDefault(); }; view.addEventListener("keydown", claimEscape, { capture: true }); return () =>