diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts index 75a03bc4843..38cf054263c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts @@ -747,7 +747,7 @@ } private static onScrollKey(e: KeyboardEvent) { - if (e.defaultPrevented || e.isComposing || e.altKey || e.ctrlKey || e.metaKey) return; + if (e.defaultPrevented || Utils.isComposingKey(e) || e.altKey || e.ctrlKey || e.metaKey) return; const space = e.key === ' ' || e.key === 'Spacebar'; if (e.shiftKey && space === false) return; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts index c1112693324..9d900443590 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts @@ -43,7 +43,7 @@ // surface around the chart, or an input method is composing. function onDocumentKeyDown(e: KeyboardEvent) { if (e.key !== 'Escape' || e.defaultPrevented) return; - if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.isComposing || e.keyCode === 229) return; + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || Utils.isComposingKey(e)) return; if (e.target instanceof Node && element.contains(e.target)) return; // Only a tooltip the pointer opened: one the keyboard position shows goes with the plot's focus. if (!element.querySelector('.bit-cht-tt') || !element.matches(':hover')) return; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts index 52b6d40c1ff..5ff72e179b7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts @@ -903,7 +903,7 @@ } private keyDownHandler = (e: KeyboardEvent) => { - if (e.isComposing) return; + if (Utils.isComposingKey(e)) return; // Escape arms the Tab escape hatch below, then lets .NET close the find panel // or leave full-screen - only the plain key, the one the textarea claims @@ -1044,7 +1044,7 @@ // selection it works on is; a custom item runs from the preview pane as well. private runItemShortcut(e: KeyboardEvent, editing: boolean): boolean { const map = this.config.shortcuts; - if (!map || e.isComposing) return false; + if (!map || Utils.isComposingKey(e)) return false; const altGraph = typeof e.getModifierState === 'function' && e.getModifierState('AltGraph'); const mod = (e.ctrlKey || e.metaKey) && !altGraph; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/BitBlazorUI.d.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/BitBlazorUI.d.ts index 32a2f141204..0b90762c171 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/BitBlazorUI.d.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/BitBlazorUI.d.ts @@ -5,6 +5,7 @@ // BitFocusablesContractTests fails on a member declared here that the core Utils no longer has. declare namespace BitBlazorUI { class Utils { + static isComposingKey(e: KeyboardEvent | InputEvent): boolean; static isFocusableElement(el: HTMLElement): boolean; static getFocusables(container: ParentNode): HTMLElement[]; static firstFocusable(container: ParentNode, skip?: (el: HTMLElement) => boolean): HTMLElement | null; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.ts b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.ts index a87ba51cf3a..e83aae2494c 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.ts @@ -28,7 +28,7 @@ // the browsers that do not set isComposing at all. Blazor listens for the keydown on // the document, so stopping the propagation here is what keeps the component from // reacting to a key that the IME is still using. - if (isComposing || e.isComposing || e.keyCode === 229) { + if (isComposing || Utils.isComposingKey(e)) { if (SearchBox.imeKeys.indexOf(e.key) > -1) { e.stopPropagation(); } @@ -83,7 +83,7 @@ if (combos.length === 0) return; const handler = (e: KeyboardEvent) => { - if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return; + if (e.defaultPrevented || Utils.isComposingKey(e)) return; const key = (e.key || '').toLowerCase(); const match = combos.find(c => c.key === key && diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.ts b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.ts index e80f09e5b26..e8023cf21b5 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.ts @@ -22,7 +22,7 @@ // the separator typed into it. The event is kept from reaching Blazor's delegated // listener on the document altogether, so nothing is committed half way through a word. // Engines that predate isComposing report the composition through the 229 key code. - if (e.isComposing || e.keyCode === 229) { + if (Utils.isComposingKey(e)) { e.stopPropagation(); return; } @@ -129,7 +129,7 @@ root.dataset.bitTgiTagsSetup = 'true'; root.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.isComposing || e.keyCode === 229) return; + if (Utils.isComposingKey(e)) return; const target = e.target as HTMLElement; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.ts b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.ts index 74c70f72135..b8d40bb8049 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.ts @@ -73,7 +73,7 @@ // Enter commits the candidate of an input method editor, so it must keep its meaning // while a composition session is running. The keyCode check covers the browsers that // report the legacy 229 instead of setting isComposing. - if (e.isComposing || e.keyCode === 229) return; + if (Utils.isComposingKey(e)) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -189,7 +189,7 @@ let isComposing = false; const composing = (e: KeyboardEvent | InputEvent) => - isComposing || e.isComposing || (e as KeyboardEvent).keyCode === 229; + isComposing || Utils.isComposingKey(e); inputElement.addEventListener('compositionstart', () => { isComposing = true; @@ -406,7 +406,7 @@ inputElement.addEventListener('keydown', e => { // Tab and Enter belong to the input method editor while a composition session is running: // they move through and commit its candidates, so the suggestion is left alone. - if (e.isComposing || e.keyCode === 229) return; + if (Utils.isComposingKey(e)) return; const isAcceptKey = e.key === 'Tab' || e.key === 'Enter'; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs index a37eadf7b6d..1392735668d 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs @@ -649,14 +649,15 @@ public async Task _OnEscape() } // Sent by the JS side as an Escape goes down inside the root or the callout while it is open, ahead of the keydown - // Blazor then dispatches, saying whether it was pressed inside a surface nested there - another callout's anchor, a - // dialog, a modal, a panel - that has spoken for it first (see Utils.watchNestedEscape, which Callouts.toggle sets - // up with the open). Only those presses are answered, and the one after them, so one that never reaches a handler - // is overwritten before the next one is read (BitEscapeVerdict). + // Blazor then dispatches, saying whether the press is another's - a surface nested there that has spoken for it + // first (another callout's anchor, a dialog, a modal, a panel), a part inside them that claimed it (see + // Utils.claimEscape), a popup opened from inside them that closes on it - rather than the callout's (see + // Utils.watchNestedEscape, which Callouts.toggle sets up with the open). Only those presses are answered, and the + // one after them, so one that never reaches a handler is overwritten before the next one is read (BitEscapeVerdict). [JSInvokable("OnEscapeVerdict")] - public void _OnEscapeVerdict(bool nested) + public void _OnEscapeVerdict(bool foreign) { - _escapeVerdict.Set(nested); + _escapeVerdict.Set(foreign); } [JSInvokable("OnTabOut")] diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs index fb962553750..a071a640594 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs @@ -915,8 +915,12 @@ private async Task HandleKeyDown(KeyboardEventArgs e) // The JS side takes an Escape that dismisses a shown tooltip before it reaches this handler, so this is // only the answer while that side is not set up yet - and a repeat of it, which finds the tooltip // already hidden, for a key it let through to a component inside the anchor that answers Escape itself. + // Only the plain key, as on the JS side: an Escape with a modifier goes on to the surface around the tooltip. + // A key cancelling an IME composition never gets here (see Utils.stopComposingEscape). if (e.Key is "Escape") { + if (e.IsPlainEscape() is false) return; + await DismissByEscape(); return; diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts index b873039170c..766d789070c 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts @@ -353,6 +353,9 @@ // Only the plain key: an Escape with a modifier goes on to the surface around the tooltip, as it // does past every component that acts on Escape (claimEscape). if (e.key !== 'Escape' || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + // A press cancelling an IME composition in a field of the anchor is the composition's: it goes on to the + // field untaken, and no further than the page's own elements (see stopComposingEscape). + if (Utils.isComposingKey(e)) return; if (!root.querySelector(':scope > .bit-ttp-wrp.bit-ttp-vis[data-bit-ttp-esc]')) return; if (e.defaultPrevented) { @@ -417,6 +420,8 @@ // on to the component rather than being stopped here. if (e.key !== 'Escape' || e.defaultPrevented) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + // A press cancelling an IME composition is the composition's, wherever the pointer rests. + if (Utils.isComposingKey(e)) return; if (Utils._tooltipsByRoot.size === 0) return; @@ -701,7 +706,7 @@ // so the surface around them closes on it. public static claimEscape(e: KeyboardEvent) { if (e.key !== 'Escape' || e.defaultPrevented) return; - if (e.isComposing || e.keyCode === 229) return; + if (Utils.isComposingKey(e)) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; const target = e.target instanceof Element ? e.target : null; @@ -728,6 +733,29 @@ && !!element.querySelector(':scope > .bit-ttp-wrp.bit-ttp-vis[data-bit-ttp-esc]'); } + // Whether the key was pressed while an input method editor (Pinyin, Kana, Hangul, ...) was composing, where + // Escape cancels the candidate rather than meaning anything to the page: such a press is nobody's, and every + // listener that closes something on Escape leaves it alone. Most engines report those keydowns as the + // "Process" key, which no listener is looking for anyway; the ones that report the real key set isComposing, + // and the ones that predate it report the legacy 229 key code instead. + public static isComposingKey(e: KeyboardEvent | InputEvent) { + return e.isComposing || (e as KeyboardEvent).keyCode === 229; + } + + // Keeps an Escape that cancels an IME composition (see isComposingKey) away from .NET. KeyboardEventArgs cannot + // tell such a press from any other - it carries no key code, and no composition state before .NET 9 - and the + // engines that report the real key for it (WebKit) would have every .NET handler that acts on Escape - a + // dropdown's search box, a picker's field, a callout around a field of the consumer's own - act on the press + // the user made to put a candidate back. general.ts calls it on the document element in the bubble phase, so + // every listener of the page's own elements has heard the key by then - the field's, the surfaces', which + // leave it alone themselves - and only Blazor's delegation on the document, and the listeners after it, do + // not: such a press then reaches .NET in no engine, where the others report it as the "Process" key anyway. + public static stopComposingEscape(e: KeyboardEvent) { + if (e.key !== 'Escape' || !Utils.isComposingKey(e)) return; + + e.stopPropagation(); + } + private static _escapeClaimers = new WeakMap(); // The element an Escape was claimed for (see claimEscape), if any. A claimer whose .NET handler hears every @@ -823,7 +851,7 @@ (e as KeyboardEvent & { __bitSurfaceEscape?: boolean }).__bitSurfaceEscape = true; if (nested) return; - if (e.isComposing || e.keyCode === 229 || e.defaultPrevented) return; + if (Utils.isComposingKey(e) || e.defaultPrevented) return; if (Callouts.componentContains(e.target as Node | null, element)) return; @@ -1014,6 +1042,9 @@ element.addEventListener('keydown', e => { if (e.key !== 'Escape' || e.defaultPrevented) return; + // An IME cancelling what it was composing is not dismissing the callout the field is in. + if (Utils.isComposingKey(e)) return; + if (Callouts.current.calloutId !== elementId) return; // A dialog, a panel or a modal in the content answered the key, and is not one of the open callouts. @@ -1029,8 +1060,9 @@ // open callouts is read before Blazor's document-level delegation lets a popup the key belongs to // (a dropdown list relocated to the body, holding the focus in its search box) close itself. document.addEventListener('keydown', e => { - // A press a component claimed (see claimEscape) is that component's, wherever its focus is. - if (e.key !== 'Escape' || e.defaultPrevented) return; + // A press a component claimed (see claimEscape) is that component's, wherever its focus is, and + // one cancelling an IME composition is the composition's. + if (e.key !== 'Escape' || e.defaultPrevented || Utils.isComposingKey(e)) return; if (Callouts.current.calloutId !== elementId) return; @@ -1117,10 +1149,11 @@ // Records a surface whose .NET keydown handlers act on Escape as the owner of the Escapes pressed inside its // elements (see _escapeSurfaceOwners), and tells those handlers, through its OnEscapeVerdict callback, which ones - // are somebody else's: a surface nested inside it owns them, or a component inside it claimed them (see + // are somebody else's: a surface nested inside it owns them, a component inside it claimed them (see // claimEscape) - another callout's anchor among them, which claims its Escape from the render that opens it, - // before its own watch is in place. A BitCallout watches its root (the anchor) and the callout while it is open - // (Callouts.toggle). + // before its own watch is in place - or a popup opened from inside it closes on them (a dropdown whose list is + // open while the focus is still on its field, see isEscapeOfOpenCallout, which `id` names the watching callout + // to). A BitCallout watches its root (the anchor) and the callout while it is open (Callouts.toggle). public static watchNestedEscape(id: string, elementIds: string[], dotnetObj: DotNetObject) { Utils.unwatchNestedEscape(id); @@ -1133,7 +1166,9 @@ const nested = Utils.claimSurfaceEscape(e, element); const claimer = Utils.escapeClaimer(e); - return nested || (!!claimer && claimer !== element && element.contains(claimer)); + return nested + || (!!claimer && claimer !== element && element.contains(claimer)) + || Utils.isEscapeOfOpenCallout(e.target, id); }); Utils._nestedEscapeWatches.set(id, controller); @@ -1187,7 +1222,7 @@ Utils.sendEscapeVerdicts([element], dotnetObj, controller.signal, 'setupEscapeGuard', e => { const foreign = Utils.isNestedSurfaceEscape(e, element) || e.defaultPrevented - || e.isComposing || e.keyCode === 229 // the engines that predate isComposing report 229 + || Utils.isComposingKey(e) || Utils.isEscapeOfOpenCallout(e.target); // A closed surface is inert, so an Escape reaches it only while it is open - and from then on the @@ -1214,10 +1249,11 @@ // Whether the innermost open callout is the one the key closes: it was pressed inside the callout or on // the component that opened it, or the callout answers an Escape from anywhere (one opened by hovering). // A callout that is only left open - one the focus has moved away from - does not close on an Escape - // pressed somewhere else, so it does not take that Escape away from the surface either. - private static isEscapeOfOpenCallout(target: EventTarget | null) { + // pressed somewhere else, so it does not take that Escape away from the surface either. The callout that is + // asking (`ownCalloutId`, see watchNestedEscape) is never one the key belongs to instead of it. + private static isEscapeOfOpenCallout(target: EventTarget | null, ownCalloutId?: string | null) { const current = Callouts.current; - if (!current.calloutId) return false; + if (!current.calloutId || current.calloutId === ownCalloutId) return false; if (Utils._escapesFromAnywhere.has(current.calloutId)) return true; @@ -1265,7 +1301,7 @@ element.addEventListener('keydown', e => { if (e.key !== 'Escape') return; - if (e.isComposing || Callouts.isOpenedFrom(element)) { + if (Utils.isComposingKey(e) || Callouts.isOpenedFrom(element)) { claimed.add(e); } }, { signal: controller.signal, capture: true }); @@ -1333,7 +1369,7 @@ window.addEventListener('keydown', e => { if (e.key !== 'Escape') return; - if (e.isComposing || Callouts.current.calloutId) { + if (Utils.isComposingKey(e) || Callouts.current.calloutId) { claimed.add(e); } }, { signal: controller.signal, capture: true }); diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/general.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/general.ts index c3f8d41d311..9bdcfa64639 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/general.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/general.ts @@ -109,6 +109,10 @@ window.addEventListener('keydown', (e: KeyboardEvent) => BitBlazorUI.Utils.claim // on the window does not depend on when the first tooltip, a menu or an overlay happened to register. window.addEventListener('keydown', (e: KeyboardEvent) => BitBlazorUI.Utils.dismissTooltipsOnEscape(e), true); +// An Escape that cancels an IME composition never reaches .NET (see Utils.stopComposingEscape): on the document element +// in the bubble phase, after every listener of the page's elements and right ahead of Blazor's delegation on the document. +document.documentElement.addEventListener('keydown', (e: KeyboardEvent) => BitBlazorUI.Utils.stopComposingEscape(e)); + // A click on the overlay of a callout opened from inside another one, outside the callouts under it as well, // dismisses them all (see Callouts.dismissOnOverlayClick). On the window in the capture phase, ahead of Blazor's // own delegation, so that the overlay's handler does not dismiss the innermost callout a second time. diff --git a/src/BlazorUI/CLAUDE.md b/src/BlazorUI/CLAUDE.md index 5d006c35f0b..334a026c2c4 100644 --- a/src/BlazorUI/CLAUDE.md +++ b/src/BlazorUI/CLAUDE.md @@ -103,6 +103,16 @@ the callouts, BitTooltip, BitMessage - the listeners in `Scripts/Utils.ts`) leav round trip that registers its popup - is claimed from the render (`data-bit-esc` on its root). A component whose .NET keydown handlers hear every key of its content leaves the keys of a surface rendered there alone through `Utils.isInNestedSurface` (BitDataGrid's cells, rows and headers, BitSwiper, BitCarousel). `BitSurfaceKeyPropagationContractTests` pins the keys reaching the page. +- **A surface that dismisses itself in its own .NET keydown handler asks the JS side first.** `BitPanel` + (`Utils.setupEscapeGuard`) and `BitCallout` (its anchor and its content, `Utils.watchNestedEscape`) go by one + per-press `OnEscapeVerdict`, which folds a nested surface, a claim, an open popup opened from inside (and, for the + panel, a prevented default and an IME composition) into one answer - never a channel of their own for one of them. + A callout names itself to `Utils.isEscapeOfOpenCallout`, since it is the innermost open callout the key closes. +- **The Escape of an IME composition is nobody's.** Every script of both packages reads it through + `Utils.isComposingKey` (the one reading of `isComposing` and the legacy 229 key code), every listener that closes + something leaves it alone, and `Utils.stopComposingEscape` stops it on the document element, ahead of Blazor's + delegation, so no .NET handler hears it in any engine - a component never tests for it in C#. + `BitEscapeClaimContractTests` pins both. - **Nothing guesses from what the target looks like.** No selector of inputs, roles or `aria-expanded` decides who owns the key; a component the claim and the open popup do not cover is fixed by claiming, never by teaching a surface a new selector. A consumer's own control opts in the same way. diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Callout/BitCalloutTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Callout/BitCalloutTests.cs index 4071f69ae70..c1dfc2bf4a6 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Callout/BitCalloutTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Callout/BitCalloutTests.cs @@ -835,6 +835,72 @@ public void BitCalloutShouldIgnoreEscapeFromTheAnchorWhileItIsClosed() Assert.AreEqual(0, toggled.Count); } + [TestMethod] + public void BitCalloutShouldNotCloseOnAnEscapeAnotherOwnsInsideIt() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, Markup("")); + }); + + component.Find(".bit-clo-acn").Click(); + + // A field inside the callout clearing, a dropdown inside it closing its list, an IME cancelling a candidate. + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(true)); + component.Find(".bit-clo-cal").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsTrue(component.Instance.IsOpen); + + // The next press is the callout's. + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(false)); + component.Find(".bit-clo-cal").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsFalse(component.Instance.IsOpen); + } + + [TestMethod] + public void BitCalloutShouldNotCloseOnAnEscapeAnotherOwnsInItsAnchor() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, Markup("")); + }); + + component.Find(".bit-clo-acn").Click(); + + // A field in the anchor that the callout opens from, the way suggestions open under a search field. + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(true)); + component.Find(".bit-clo").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsTrue(component.Instance.IsOpen); + + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(false)); + component.Find(".bit-clo").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsFalse(component.Instance.IsOpen); + } + + [TestMethod] + public void BitCalloutShouldReadTheVerdictOfAnEscapeOnlyOnce() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, Markup("")); + }); + + component.Find(".bit-clo-acn").Click(); + + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(true)); + component.Find(".bit-clo-cal").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsTrue(component.Instance.IsOpen); + + // The answer was given for the press that has been handled, so it does not hold back the one after it. + component.Find(".bit-clo-cal").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsFalse(component.Instance.IsOpen); + } + [TestMethod] public void BitCalloutShouldNotLetANestedEscapeDismissTheCalloutItIsNestedIn() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Tooltip/BitTooltipTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Tooltip/BitTooltipTests.cs index 31fc60debec..44302ca900b 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Tooltip/BitTooltipTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Tooltip/BitTooltipTests.cs @@ -627,6 +627,29 @@ public void BitTooltipShouldBeDismissedByEscape() Assert.IsFalse(component.Find(".bit-ttp-wrp").ClassList.Contains("bit-ttp-vis")); } + [TestMethod, + DataRow(true, false, false, false), + DataRow(false, true, false, false), + DataRow(false, false, true, false), + DataRow(false, false, false, true)] + public void BitTooltipShouldNotBeDismissedByAModifiedEscape(bool shift, bool ctrl, bool alt, bool meta) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Text, "Tip"); + parameters.Add(p => p.DefaultIsShown, true); + }); + + // An Escape with a modifier goes on to the surface around the tooltip, as the JS side leaves it. + component.Find(".bit-ttp").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape", ShiftKey = shift, CtrlKey = ctrl, AltKey = alt, MetaKey = meta }); + + Assert.IsTrue(component.Find(".bit-ttp-wrp").ClassList.Contains("bit-ttp-vis")); + + component.Find(".bit-ttp").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsFalse(component.Find(".bit-ttp-wrp").ClassList.Contains("bit-ttp-vis")); + } + [TestMethod] public void BitTooltipShouldIgnoreOtherKeys() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs index a2e54b26c9f..df9b94bbaa9 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs @@ -97,6 +97,112 @@ public void EverySurfaceShouldLeaveAClaimedEscapeAlone(string method) } } + [TestMethod] + public void TheCompositionOfAKeyShouldBeReadInOnePlace() + { + var check = GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), "isComposingKey"); + + // The engines that report the real key during a composition set isComposing, and the ones that predate it + // report the legacy 229 key code instead - so a check of one of them alone leaves a dialog closing in the other. + StringAssert.Contains(check, "e.isComposing"); + StringAssert.Contains(check, "keyCode === 229"); + + // Every script of both packages: the components' own (a text field holding back the keys of a composition, a + // search box, a chart's tooltip) as well as the library's surfaces. The Extras ones call the core helper + // through its declaration, which is no copy of it. + string[] packages = [SourceFiles.GetDirectory("Bit.BlazorUI"), SourceFiles.GetDirectory("Bit.BlazorUI.Extras")]; + + var scripts = packages.SelectMany(folder => Directory.EnumerateFiles(folder, "*.ts", SearchOption.AllDirectories)) + .Where(file => file.EndsWith(".d.ts") is false) + .Where(file => file.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(part => part is "node_modules" or "wwwroot" or "bin" or "obj") is false) + .ToList(); + + Assert.IsTrue(scripts.Count > 1, "No script of the packages was found."); + + var copies = scripts.SelectMany(file => SourceFiles.ReadFullPath(file).Split('\n') + .Where(line => line.TrimStart().StartsWith("//") is false) + .Where(line => Regex.IsMatch(line, @"\.isComposing\b|=== 229")) + .Where(line => check.Contains(line.Trim()) is false) + .Select(line => $"{Path.GetFileName(file)}: {line.Trim()}")) + .ToList(); + + Assert.AreEqual(0, copies.Count, $"A script reads the composition of a key outside Utils.isComposingKey: {string.Join(" | ", copies)}"); + } + + [TestMethod, + DataRow("claimEscape"), + DataRow("setupSurfaceEscape"), + DataRow("setupEscapeGuard"), + DataRow("watchEscape"), + DataRow("watchLayerEscape"), + DataRow("setupEscape"), + DataRow("setupTooltip"), + DataRow("dismissTooltipsOnEscape"), + DataRow("stopComposingEscape") + ] + public void EverySurfaceShouldLeaveTheEscapeOfAnImeCompositionAlone(string method) + { + var body = GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), method); + + // Escape cancels the candidate an input method editor is composing, so the press is nobody's: a surface that + // closed on it would take away the text the user was typing along with it. + StringAssert.Contains(body, "Utils.isComposingKey(e)", $"Utils.{method} does not leave the Escape of an IME composition alone."); + } + + [TestMethod, + DataRow("setupSurfaceEscape"), + DataRow("setupEscape"), + DataRow("setupTooltip") + ] + public void EveryListenerThatAnswersTheKeyShouldReadTheCompositionItself(string method) + { + var body = GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), method); + + // As with a claim, one listener of the method reading it does not keep another one from closing the surface. + // (watchEscape and watchLayerEscape record it in a listener of their own, which the answering one reads.) + var listeners = body.Split("addEventListener(").Skip(1) + .Where(listener => listener.StartsWith("'keydown'")) + .Where(listener => listener.Contains("invokeMethodAsync")) + .ToList(); + + Assert.IsTrue(listeners.Count > 0, $"Utils.{method} has no keydown listener that answers the key."); + + foreach (var listener in listeners) + { + StringAssert.Contains(listener, "Utils.isComposingKey(e)", $"A keydown listener of Utils.{method} answers the Escape of an IME composition."); + } + } + + [TestMethod] + public void TheEscapeOfAnImeCompositionShouldNeverReachDotNet() + { + // KeyboardEventArgs cannot tell it from any other Escape (no key code, and no composition state before .NET 9), + // so it is stopped on the document element in the bubble phase: after every listener of the page's elements, + // and ahead of Blazor's delegation, which listens on the document. + var general = SourceFiles.Read("Bit.BlazorUI", "Scripts", "general.ts"); + + StringAssert.Contains(general, "document.documentElement.addEventListener('keydown', (e: KeyboardEvent) => BitBlazorUI.Utils.stopComposingEscape(e));"); + + var stop = GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), "stopComposingEscape"); + + StringAssert.Contains(stop, "e.key !== 'Escape'"); + StringAssert.Contains(stop, "e.stopPropagation()"); + Assert.IsFalse(stop.Contains("preventDefault"), "Utils.stopComposingEscape takes the key away from the IME it belongs to."); + } + + [TestMethod] + public void TheCalloutShouldLeaveAnEscapeOfAPopupOpenedFromInsideItAlone() + { + // A BitCallout dismisses itself in .NET, on its root and on its content, which hear a press a popup opened from + // inside them closes, too - so the watch on its Escapes asks about the open callouts as a panel's guard does, and + // is told which callout is asking: that one is the innermost open callout the key closes, never another. + var utils = SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"); + + StringAssert.Contains(GetMethod(utils, "watchNestedEscape"), "Utils.isEscapeOfOpenCallout(e.target, id)"); + StringAssert.Contains(GetMethod(utils, "isEscapeOfOpenCallout"), "current.calloutId === ownCalloutId"); + } + [TestMethod, DataRow("setupSurfaceEscape"), DataRow("setupEscapeGuard"), diff --git a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance.TestHost/Components/Pages/BitEscapeClaim.razor b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance.TestHost/Components/Pages/BitEscapeClaim.razor index 08c4f1626e1..d62fa5e1b5d 100644 --- a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance.TestHost/Components/Pages/BitEscapeClaim.razor +++ b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance.TestHost/Components/Pages/BitEscapeClaim.razor @@ -8,7 +8,11 @@ with a modifier) is the surface's straight away. The page-level section covers the claims that have to stop at a surface rendered inside the claimer (a Modal inside a Message), and the listeners that hear the key from anywhere (a Tooltip shown on hover). A Tooltip inside the surface takes a press nothing in its anchor acts on, and goes - along with one a field claims or a popup of its anchor closes on - the surface waiting for the next press either way. *@ + along with one a field claims or a popup of its anchor closes on - the surface waiting for the next press either way. + A Callout is one of those surfaces too, its own .NET handlers guarded like the others (Utils.setupEscapeGuard), and + the page-level one opened from a field in its anchor waits while that field clears. An Escape cancelling an IME + composition is nobody's: the surface and a Tooltip shown in it both stay, and so does the list of a Dropdown whose + search box it was pressed in, which .NET never hears it from (Utils.stopComposingEscape). *@

Escape Claim Regression (@Surface)

@@ -23,6 +27,8 @@

Message: @(_msgDismissed ? "dismissed" : "shown")

Message field: @_mtf

Tooltip field: @_ttf

+

Anchor callout: @(_anchorCalloutOpen ? "open" : "closed")

+

Anchor field: @_actf

Clears: @_clears

Status: @_status

@@ -44,6 +50,10 @@ @Content break; + case "callout": + + break; + default: break; @@ -67,6 +77,23 @@

Page field: @_ptf

+ + @* A Callout opened from a field in its anchor, the way suggestions open under a search field: the Escape the field + clears itself with is the field's, and the callout closes on the next press. *@ + + +
+
+ +
Suggestions
+
+
+ + @* A Tooltip shown by the focus on its anchor leaves an Escape with a modifier to the page around it. *@ +
+ + @* A Dropdown whose list holds a search box, which .NET answers the keys of. *@ +
@code { @@ -92,14 +119,21 @@ private string? _ptf; private string? _ttf; private bool _outerMsgDismissed; + private bool _anchorCalloutOpen; + private string? _actf; + private string? _pdrp = "a"; private bool _innerModalOpen; private int _clears; // "Loading" is shown in the SSR pre-rendered HTML before the SignalR circuit connects; only the first // interactive render moves it on, which is what the test waits for before it drives the page. private string _status = "Loading"; + private RenderFragment CalloutAnchor => @; + private RenderFragment Content => @
+ @* A field of the page's own, with no guard of its own against an IME's keys. *@ +
diff --git a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitEscapeClaimBrowserTests.cs b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitEscapeClaimBrowserTests.cs index f0e795fcec1..a8dfc8f616c 100644 --- a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitEscapeClaimBrowserTests.cs +++ b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitEscapeClaimBrowserTests.cs @@ -17,7 +17,7 @@ namespace Bit.BlazorUI.Tests.Performance; [RequiresBrowser] // opt-in: RUN_BROWSER_TESTS=1 dotnet test --filter FullyQualifiedName~BitEscapeClaimBrowserTests public class BitEscapeClaimBrowserTests : PerformanceTestBase { - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task TextField_ClearOnEscape_ClearsFirstAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -31,7 +31,7 @@ public async Task TextField_ClearOnEscape_ClearsFirstAndTheSurfaceClosesOnTheNex await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task TextField_ClearOnEscape_OneWayBinding_LeavesTheKeyToTheSurface(string surface) { await OpenSurface(surface); @@ -42,7 +42,7 @@ public async Task TextField_ClearOnEscape_OneWayBinding_LeavesTheKeyToTheSurface await Expect(Page.Locator("#clears")).ToHaveTextAsync("0"); } - [TestMethod, DataRow("dialog", "Shift+Escape"), DataRow("modal", "Control+Escape"), DataRow("panel", "Alt+Escape"), DataRow("overlay", "Shift+Escape")] + [TestMethod, DataRow("dialog", "Shift+Escape"), DataRow("modal", "Control+Escape"), DataRow("panel", "Alt+Escape"), DataRow("overlay", "Shift+Escape"), DataRow("callout", "Control+Escape")] public async Task TextField_ClearOnEscape_ModifiedEscape_ClosesTheSurfaceAndClearsNothing(string surface, string key) { await OpenSurface(surface); @@ -57,7 +57,7 @@ public async Task TextField_ClearOnEscape_ModifiedEscape_ClosesTheSurfaceAndClea await Expect(Page.Locator("#clears")).ToHaveTextAsync("0"); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task SearchBox_ClearsFirstAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -71,7 +71,7 @@ public async Task SearchBox_ClearsFirstAndTheSurfaceClosesOnTheNextPress(string await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task SearchBox_OneWayBinding_LeavesTheKeyToTheSurface(string surface) { await OpenSurface(surface); @@ -82,7 +82,7 @@ public async Task SearchBox_OneWayBinding_LeavesTheKeyToTheSurface(string surfac await Expect(Page.Locator("#clears")).ToHaveTextAsync("0"); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task NumberField_ClearsFirstAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -96,7 +96,7 @@ public async Task NumberField_ClearsFirstAndTheSurfaceClosesOnTheNextPress(strin await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task TagsInput_TakesBackTheTextThenTheTagsAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -113,7 +113,7 @@ public async Task TagsInput_TakesBackTheTextThenTheTagsAndTheSurfaceClosesOnTheN await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task TagsInput_OneWayBinding_LeavesTheKeyToTheSurface(string surface) { await OpenSurface(surface); @@ -124,7 +124,7 @@ public async Task TagsInput_OneWayBinding_LeavesTheKeyToTheSurface(string surfac await Expect(Page.Locator("#clears")).ToHaveTextAsync("0"); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Dropdown_ClearOnEscape_ClearsFirstAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -138,7 +138,7 @@ public async Task Dropdown_ClearOnEscape_ClearsFirstAndTheSurfaceClosesOnTheNext await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Dropdown_ClosesItsListFirstAndKeepsTheSelection(string surface) { await OpenSurface(surface); @@ -152,7 +152,7 @@ public async Task Dropdown_ClosesItsListFirstAndKeepsTheSelection(string surface await Expect(Page.Locator("#v-drp")).ToHaveTextAsync("a"); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Dropdown_ClearOnEscape_OneWayBinding_LeavesTheKeyToTheSurface(string surface) { await OpenSurface(surface); @@ -163,7 +163,7 @@ public async Task Dropdown_ClearOnEscape_OneWayBinding_LeavesTheKeyToTheSurface( await Expect(Page.Locator("#clears")).ToHaveTextAsync("0"); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Dropdown_ModifiedEscape_ClosesTheSurfaceAndKeepsTheSelection(string surface) { await OpenSurface(surface); @@ -177,7 +177,7 @@ public async Task Dropdown_ModifiedEscape_ClosesTheSurfaceAndKeepsTheSelection(s await Expect(Page.Locator("#clears")).ToHaveTextAsync("0"); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Combo_DropsTheTermThenTheSelectionAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -197,7 +197,7 @@ public async Task Combo_DropsTheTermThenTheSelectionAndTheSurfaceClosesOnTheNext await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Message_DismissesFirstAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -213,7 +213,7 @@ public async Task Message_DismissesFirstAndTheSurfaceClosesOnTheNextPress(string await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Message_AFieldInsideItClearsAndTheMessageStays(string surface) { await OpenSurface(surface); @@ -274,7 +274,7 @@ public async Task Tooltip_LeavesAnEscapeAFieldClaimedToTheField() await Expect(tooltip).ToHaveCountAsync(0); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Tooltip_AroundAFieldThatClaimsTheKey_GoesAlongAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -292,7 +292,7 @@ public async Task Tooltip_AroundAFieldThatClaimsTheKey_GoesAlongAndTheSurfaceClo await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Tooltip_AroundAFieldThatLeavesTheKeyAlone_TakesItAndTheSurfaceClosesOnTheNextPress(string surface) { await OpenSurface(surface); @@ -308,7 +308,7 @@ public async Task Tooltip_AroundAFieldThatLeavesTheKeyAlone_TakesItAndTheSurface await PressEscapeAndExpectClosed(); } - [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay")] + [TestMethod, DataRow("dialog"), DataRow("modal"), DataRow("panel"), DataRow("overlay"), DataRow("callout")] public async Task Tooltip_AroundAnOpenPopup_GoesAlongAsThePopupClosesAndTheSurfaceStays(string surface) { await OpenSurface(surface); @@ -328,6 +328,140 @@ public async Task Tooltip_AroundAnOpenPopup_GoesAlongAsThePopupClosesAndTheSurfa await Expect(tooltip).ToHaveCountAsync(0); } + [TestMethod] + public async Task Callout_AFieldInItsAnchorClearsFirstAndTheCalloutClosesOnTheNextPress() + { + await OpenPage(); + + await Page.Locator("#w-actf input").ClickAsync(); + await Expect(Page.Locator("#anchor-callout-state")).ToHaveTextAsync("open"); + await Settle(); + + await FocusAndType("#w-actf input", "abc"); + await Expect(Page.Locator("#v-actf")).ToHaveTextAsync("abc"); + + await Page.Keyboard.PressAsync("Escape"); + + await Expect(Page.Locator("#v-actf")).ToHaveTextAsync(""); + + // Give a wrongly routed press the time to have closed the callout. + await Page.WaitForTimeoutAsync(300); + await Expect(Page.Locator("#anchor-callout-state")).ToHaveTextAsync("open"); + + // The field holds nothing now, so the next press is the callout's. + await Page.Keyboard.PressAsync("Escape"); + + await Expect(Page.Locator("#anchor-callout-state")).ToHaveTextAsync("closed"); + } + + [TestMethod, DataRow("Shift+Escape"), DataRow("Control+Escape"), DataRow("Alt+Escape")] + public async Task Tooltip_LeavesAModifiedEscapeToThePage(string key) + { + await OpenPage(); + + await Page.Locator("#tooltip-btn").FocusAsync(); + var tooltip = Page.Locator("#w-pbt .bit-ttp-wrp.bit-ttp-vis"); + await Expect(tooltip).ToHaveCountAsync(1); + await Settle(); + + await Page.Keyboard.PressAsync(key); + + // Give a wrongly routed press the time to have hidden the tooltip. + await Page.WaitForTimeoutAsync(300); + await Expect(tooltip).ToHaveCountAsync(1); + + await Page.Keyboard.PressAsync("Escape"); + + await Expect(tooltip).ToHaveCountAsync(0); + } + + [TestMethod, DataRow("isComposing"), DataRow("keyCode")] + public async Task Dropdown_EscapeCancellingAnImeCompositionInItsSearchBox_LeavesTheListOpen(string flag) + { + await OpenPage(); + + var trigger = Page.Locator("#w-pdrp .bit-drp-wrp"); + await trigger.ClickAsync(); + await Expect(trigger).ToHaveAttributeAsync("aria-expanded", "true"); + await Settle(); + + var searchBox = Page.Locator("input.bit-drp-sin"); + await searchBox.FocusAsync(); + + await DispatchComposingEscape("input.bit-drp-sin", flag); + + // Give a wrongly routed press the time to have closed the list. + await Page.WaitForTimeoutAsync(300); + await Expect(trigger).ToHaveAttributeAsync("aria-expanded", "true"); + + // The composition has ended, and the next press is the list's. + await Page.Keyboard.PressAsync("Escape"); + + await Expect(trigger).ToHaveAttributeAsync("aria-expanded", "false"); + } + + // An Escape pressed to cancel what an input method editor is composing is dispatched by hand: no engine driven here + // has an IME to compose with. The engines that report the real key during a composition set isComposing, and the + // ones that predate it report the legacy 229 key code instead, so both are pinned for every surface. + [TestMethod, + DataRow("dialog", "isComposing"), DataRow("dialog", "keyCode"), + DataRow("modal", "isComposing"), DataRow("modal", "keyCode"), + DataRow("panel", "isComposing"), DataRow("panel", "keyCode"), + DataRow("overlay", "isComposing"), DataRow("overlay", "keyCode"), + DataRow("callout", "isComposing"), DataRow("callout", "keyCode")] + public async Task EscapeCancellingAnImeComposition_LeavesTheSurfaceOpenAndTheNextPressClosesIt(string surface, string flag) + { + await OpenSurface(surface); + + await Page.Locator("#ime-input").FocusAsync(); + + await DispatchComposingEscape("#ime-input", flag); + + // Give a wrongly routed press the time to have closed the surface. + await Page.WaitForTimeoutAsync(300); + await Expect(Page.Locator("#surface-state")).ToHaveTextAsync("open"); + + // The composition has ended, and the next press is the surface's. + await PressEscapeAndExpectClosed(); + } + + [TestMethod, + DataRow("dialog", "isComposing"), DataRow("modal", "keyCode"), DataRow("panel", "isComposing"), + DataRow("overlay", "keyCode"), DataRow("callout", "isComposing")] + public async Task Tooltip_EscapeCancellingAnImeCompositionInItsAnchor_LeavesTheTooltipAndTheSurface(string surface, string flag) + { + await OpenSurface(surface); + + await Page.Locator("#w-tpf input").FocusAsync(); + var tooltip = Page.Locator("#w-tpf .bit-ttp-wrp.bit-ttp-vis"); + await Expect(tooltip).ToHaveCountAsync(1); + + await DispatchComposingEscape("#w-tpf input", flag); + + await Page.WaitForTimeoutAsync(300); + await Expect(tooltip).ToHaveCountAsync(1); + await Expect(Page.Locator("#surface-state")).ToHaveTextAsync("open"); + + // The press after the composition is the tooltip's, as it always was. + await PressEscapeAndExpectOpen(); + await Expect(tooltip).ToHaveCountAsync(0); + } + + private async Task DispatchComposingEscape(string selector, string flag) + { + await Page.Locator(selector).EvaluateAsync( + """ + (element, flag) => { + const e = new KeyboardEvent('keydown', { + key: 'Escape', code: 'Escape', bubbles: true, cancelable: true, composed: true, + isComposing: flag === 'isComposing' + }); + if (flag === 'keyCode') Object.defineProperty(e, 'keyCode', { get: () => 229 }); + element.dispatchEvent(e); + } + """, flag); + } + private async Task OpenPage(string? surface = null) { await Page.GotoAsync($"{BaseUrl}/regression/escape-claim{(surface is null ? "" : $"?surface={surface}")}");