From 4ce68719d89e8a7ada6db385864441120d1a35d9 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 16:07:39 +0330 Subject: [PATCH 1/2] fix Esc key issues with popups in BlazorUI #13568 --- .../Surfaces/Callout/BitCallout.razor.cs | 43 ++++++++- .../Surfaces/Tooltip/BitTooltip.razor.cs | 22 +++++ .../JsInterop/CalloutsJsRuntimeExtensions.cs | 12 +-- src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts | 16 +++- src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts | 76 ++++++++++++++-- .../Surfaces/Callout/BitCalloutTests.cs | 83 ++++++++++++++++++ .../Surfaces/Tooltip/BitTooltipTests.cs | 21 +++++ .../Utils/BitEscapeClaimContractTests.cs | 87 +++++++++++++++++++ .../Components/Pages/BitEscapeClaim.razor | 12 ++- .../BitEscapeClaimBrowserTests.cs | 62 +++++++++++++ 10 files changed, 413 insertions(+), 21 deletions(-) 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 e21dda1c788..c3fe7e23bb1 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs @@ -28,6 +28,10 @@ public partial class BitCallout : BitComponentBase private bool _focusTrapped; private bool _focusOriginCaptured; private bool _escapeRegistered; + private bool _escapeCompositionWatched; + // Whether the Escape the key handlers are about to hear cancels an IME composition, as the browser answered it + // (see _OnEscapeComposition), taken by the handler as it starts. + private bool _composingEscape; private bool _tabOutRegistered; private bool _scrollLocked; private bool _hoverInside; @@ -646,6 +650,15 @@ public async Task _OnEscape() await InvokeAsync(StateHasChanged); } + [JSInvokable("OnEscapeComposition")] + public void _OnEscapeComposition(bool composing) + { + // Sent for every Escape that goes through the root or the callout while it is open, ahead of the keydown + // Blazor dispatches for it (see Utils.watchEscapeComposition): KeyboardEventArgs cannot say whether the key + // cancelled an IME composition - it carries no key code, and no composition state before .NET 9. + _composingEscape = composing; + } + [JSInvokable("OnTabOut")] public async Task _OnTabOut() { @@ -886,6 +899,8 @@ private async Task HandleOnCalloutClick() // listener leaves the anchor out, so this is the one place the key is answered for it. private async Task HandleOnRootKeyDown(KeyboardEventArgs e) { + if (TakeComposingEscape(e)) return; + if (Disabled || IsOpen is false) return; if (e.Key is not "Escape" || NoDismissOnEscape) return; @@ -900,6 +915,8 @@ private async Task HandleOnRootKeyDown(KeyboardEventArgs e) private async Task HandleOnCalloutKeyDown(KeyboardEventArgs e) { + if (TakeComposingEscape(e)) return; + if (Disabled || IsOpen is false) return; if (e.Key is not "Escape" || NoDismissOnEscape) return; @@ -915,6 +932,19 @@ private async Task HandleOnCalloutKeyDown(KeyboardEventArgs e) StateHasChanged(); } + // Whether the key is an Escape that cancels what an IME was composing in a field of the anchor or of the callout: + // the press is the composition's, never a dismissal. The browser's answer is read once, so it never outlives the + // press it was given for. + private bool TakeComposingEscape(KeyboardEventArgs e) + { + if (e.Key is not "Escape") return false; + + var composing = _composingEscape; + _composingEscape = false; + + return composing; + } + private async Task HandleOnMouseEnter() { if (HoverDriven is false) return; @@ -1321,6 +1351,8 @@ private async Task ReturnFocus(bool restore) // - The element the focus is on is remembered, by a callout without an anchor of its own - an external one, // or a point: the one it renders is otherwise where the focus goes back to. // - Escape is listened for on the page (see ListensForEscapeOnPage). + // - The Escapes the key handlers of the root and of the callout hear are told apart from the ones cancelling an + // IME composition (see _OnEscapeComposition). // - Tab and Shift+Tab are kept inside a callout that keeps the focus, or the content of one that does not is // put into the tab order right after its trigger (see GetTabOutTriggerId). private async Task SetupKeyboard() @@ -1329,18 +1361,21 @@ private async Task SetupKeyboard() var captureFocusOrigin = HasOwnAnchor is false && _focusOriginCaptured is false; var escape = ListensForEscapeOnPage && _escapeRegistered is false; + var escapeComposition = _escapeCompositionWatched is false; var focusTrap = KeepsFocus && _focusTrapped is false; var tabOutTriggerId = _tabOutRegistered ? null : GetTabOutTriggerId(); - if ((captureFocusOrigin || escape || focusTrap || tabOutTriggerId is not null) is false) return; + if ((captureFocusOrigin || escape || escapeComposition || focusTrap || tabOutTriggerId is not null) is false) return; _escapeRegistered |= escape; + _escapeCompositionWatched |= escapeComposition; _focusTrapped |= focusTrap; _tabOutRegistered |= tabOutTriggerId is not null; try { - var captured = await _js.BitCalloutSetupKeyboard(_contentId, _dotnetObj, captureFocusOrigin, escape, _Id, focusTrap, tabOutTriggerId); + var captured = await _js.BitCalloutSetupKeyboard(_contentId, _dotnetObj, captureFocusOrigin, escape, _Id, focusTrap, tabOutTriggerId, + escapeComposition ? _Id : null); // A focus that was on the body leaves nothing to hand back to, which ReturnFocus has to know to fall // back to the anchor instead. @@ -1358,9 +1393,11 @@ private async Task DisposeKeyboard(bool forgetFocusOrigin) { var forget = forgetFocusOrigin && _focusOriginCaptured; - if ((_escapeRegistered || _focusTrapped || _tabOutRegistered || forget) is false) return; + if ((_escapeRegistered || _escapeCompositionWatched || _focusTrapped || _tabOutRegistered || forget) is false) return; _escapeRegistered = false; + _escapeCompositionWatched = false; + _composingEscape = false; _focusTrapped = false; _tabOutRegistered = false; 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..63dac407fac 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs @@ -59,6 +59,10 @@ public partial class BitTooltip : BitComponentBase // two apart for a tooltip a click opened: a Tab away dismisses it, a press on its own text does not. private bool _isPressWithin; + // Whether the Escape the key handler is about to hear cancels an IME composition in a field of the anchor, as the + // browser answered it (see _OnEscapeComposition), taken by the handler as it starts. + private bool _composingEscape; + // The relationship last mirrored onto the anchor, so that the round trip to the DOM is made when what // it would write has actually changed rather than once per render. private string _syncedAria = string.Empty; @@ -578,6 +582,17 @@ public async Task _OnEscape() await DismissByEscape(); } + /// + /// An Escape pressed in the anchor to cancel what an IME was composing, which the JS side leaves to the field and + /// tells the key handler about ahead of the keydown Blazor dispatches for it: KeyboardEventArgs cannot say so - it + /// carries no key code, and no composition state before .NET 9. + /// + [JSInvokable("OnEscapeComposition")] + public void _OnEscapeComposition(bool composing) + { + _composingEscape = composing; + } + /// /// A press the JS side saw land outside the component while a press of the anchor held the tooltip open. /// Safari and every touch browser leave a pressed button unfocused, so the focus leaving it - which is what @@ -915,8 +930,15 @@ 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. + // A key cancelling an IME composition, which it lets through as well, is the composition's alone; that answer + // is read once, so it never outlives the press it was given for. if (e.Key is "Escape") { + var composing = _composingEscape; + _composingEscape = false; + + if (composing) return; + await DismissByEscape(); return; diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs index 3899f538d7d..32f71061b1b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs @@ -135,9 +135,10 @@ internal static ValueTask BitCalloutClearCallout(this IJSRuntime jsRuntime, stri } // Hands the keyboard over to an opening BitCallout in one round trip: remembers the element the focus is on, - // listens for Escape on the page, traps the focus, or puts the callout in the tab order after its trigger - - // each only where asked for (a null trigger id asks for no tab-out). Returns whether a focus origin was - // remembered, which it is not for a focus that was on the body. + // listens for Escape on the page, tells the Escapes of an IME composition apart on the root named by + // compositionRootId and on the callout, traps the focus, or puts the callout in the tab order after its + // trigger - each only where asked for (a null id asks for no composition watch, a null trigger id for no + // tab-out). Returns whether a focus origin was remembered, which it is not for a focus that was on the body. internal static ValueTask BitCalloutSetupKeyboard<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( this IJSRuntime jsRuntime, string calloutId, @@ -146,10 +147,11 @@ internal static ValueTask BitCalloutClearCallout(this IJSRuntime jsRuntime, stri bool escape, string? escapeTriggerId, bool focusTrap, - string? tabOutTriggerId) where T : class + string? tabOutTriggerId, + string? compositionRootId) where T : class { return jsRuntime.Invoke("BitBlazorUI.Callouts.setupKeyboard", - calloutId, dotnetObj, captureFocusOrigin, escape, escapeTriggerId, focusTrap, tabOutTriggerId); + calloutId, dotnetObj, captureFocusOrigin, escape, escapeTriggerId, focusTrap, tabOutTriggerId, compositionRootId); } // Takes back everything BitCalloutSetupKeyboard may have set up, in one round trip. diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts index 785a76a0ed8..06172cf5234 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts @@ -1684,9 +1684,11 @@ // Hands the keyboard over to an opening BitCallout in one call rather than one per piece, since each // is a round trip of its own on a server-rendered page and all of them stand between the click and the // focus moving into the callout: the element the focus is on is remembered (to be handed back to on - // close), Escape is listened for on the page, Tab and Shift+Tab are kept inside the callout, or the - // callout is put into the tab order after its trigger. Each is asked for on its own, and the ones the - // component has already set up are not asked for again. Reports whether a focus origin was remembered. + // close), Escape is listened for on the page, the Escapes the component's own handlers hear on the root + // and on the callout are told apart from the ones cancelling an IME composition, Tab and Shift+Tab are + // kept inside the callout, or the callout is put into the tab order after its trigger. Each is asked for + // on its own, and the ones the component has already set up are not asked for again. Reports whether a + // focus origin was remembered. public static setupKeyboard( calloutId: string, dotnetObj: DotNetObject, @@ -1694,13 +1696,18 @@ escape: boolean, escapeTriggerId: string | null, focusTrap: boolean, - tabOutTriggerId: string | null) { + tabOutTriggerId: string | null, + compositionRootId: string | null) { const captured = captureFocusOrigin && Utils.captureFocusOrigin(calloutId); if (escape) { Utils.setupEscape(calloutId, dotnetObj, escapeTriggerId); } + if (compositionRootId) { + Utils.watchEscapeComposition(calloutId, [compositionRootId, calloutId], dotnetObj); + } + if (focusTrap) { Utils.setupFocusTrap(calloutId); } @@ -1719,6 +1726,7 @@ Utils.disposeFocusTrap(calloutId); Utils.disposeTabOut(calloutId); Utils.disposeEscape(calloutId); + Utils.unwatchEscapeComposition(calloutId); if (forgetFocusOrigin) { Utils.disposeFocusOrigin(calloutId); diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts index a7dc08c97cc..f2918ce0ddf 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts @@ -350,9 +350,20 @@ const controller = new AbortController(); root.addEventListener('keydown', e => { + if (e.key !== 'Escape') return; + + // A press cancelling an IME composition in a field of the anchor is the composition's: it goes on to + // the field and the page untaken, and the tooltip's own .NET key handler, which hears it on the way and + // cannot tell it apart itself, is told so ahead of it. + if (Utils.isComposingKey(e)) { + dotnetObj.invokeMethodAsync('OnEscapeComposition', true) + .catch(err => console.error("BitBlazorUI.Utils.setupTooltip:", err)); + return; + } + // 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; + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (!root.querySelector(':scope > .bit-ttp-wrp.bit-ttp-vis[data-bit-ttp-esc]')) return; if (e.defaultPrevented) { @@ -417,6 +428,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; @@ -698,7 +711,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; @@ -725,6 +738,15 @@ && !!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) { + return e.isComposing || e.keyCode === 229; + } + 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 +845,7 @@ if (event.__bitSurfaceEscape) return; event.__bitSurfaceEscape = true; - 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 +1036,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; dotnetObj.invokeMethodAsync('OnEscape'); @@ -1026,8 +1051,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; @@ -1052,6 +1078,40 @@ Utils.forgetEscapeSurface(elementId); } + private static _escapeCompositionWatches = new Map(); + + // Tells a component whose .NET keydown handlers dismiss it on Escape (a callout, on its anchor and on its + // content) whether the Escape a handler is about to hear was pressed to cancel what an input method editor was + // composing (see isComposingKey) - which .NET cannot read for itself: KeyboardEventArgs carries no key code, + // and no composition state before .NET 9. Every Escape is answered, through the OnEscapeComposition callback, + // so an answer never outlives the press it was given for. The listeners are on the elements the handlers are + // on, so they run before Blazor's document-level delegation, and the answer is queued ahead of the keydown + // Blazor dispatches right after it. + public static watchEscapeComposition(id: string, elementIds: string[], dotnetObj: DotNetObject) { + Utils.unwatchEscapeComposition(id); + + const controller = new AbortController(); + + for (const elementId of elementIds) { + document.getElementById(elementId)?.addEventListener('keydown', e => { + if (e.key !== 'Escape') return; + + dotnetObj.invokeMethodAsync('OnEscapeComposition', Utils.isComposingKey(e)) + .catch(err => console.error("BitBlazorUI.Utils.watchEscapeComposition:", err)); + }, { signal: controller.signal }); + } + + Utils._escapeCompositionWatches.set(id, controller); + } + + public static unwatchEscapeComposition(id: string) { + const controller = Utils._escapeCompositionWatches.get(id); + if (!controller) return; + + controller.abort(); + Utils._escapeCompositionWatches.delete(id); + } + // The callouts that answer an Escape pressed anywhere in the page (setupEscape with a trigger), not only // one pressed inside them or on their trigger. private static _escapesFromAnywhere = new Set(); @@ -1091,7 +1151,7 @@ const foreign = Utils._claimedEscapes.has(e) || 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 @@ -1167,7 +1227,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 }); @@ -1232,7 +1292,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/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Callout/BitCalloutTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Callout/BitCalloutTests.cs index 4416f4af222..ea3251ed12b 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 @@ -813,6 +813,89 @@ public void BitCalloutShouldIgnoreEscapeFromTheAnchorWhileItIsClosed() Assert.AreEqual(0, toggled.Count); } + [TestMethod] + public void BitCalloutShouldWatchItsRootAndItselfForTheEscapesOfAnImeCompositionAsItOpens() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, Markup("")); + }); + + component.Find(".bit-clo-acn").Click(); + + // KeyboardEventArgs cannot say whether an Escape cancelled what an IME was composing, so the browser tells the + // key handlers of the root and of the callout, in the same round trip as the rest of the keyboard. + var setup = Context.JSInterop.Invocations["BitBlazorUI.Callouts.setupKeyboard"][^1].Arguments; + + Assert.AreEqual(component.Find(".bit-clo-cal").Id, setup[0]); + Assert.AreEqual(component.Find(".bit-clo").Id, setup[7]); + } + + [TestMethod] + public void BitCalloutShouldNotCloseOnAnEscapeThatCancelsAnImeCompositionInsideIt() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, Markup("")); + }); + + component.Find(".bit-clo-acn").Click(); + + component.InvokeAsync(() => component.Instance._OnEscapeComposition(true)); + component.Find(".bit-clo-cal").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsTrue(component.Instance.IsOpen); + + // The composition has ended, and the next press is the callout's. + component.InvokeAsync(() => component.Instance._OnEscapeComposition(false)); + component.Find(".bit-clo-cal").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsFalse(component.Instance.IsOpen); + } + + [TestMethod] + public void BitCalloutShouldNotCloseOnAnEscapeThatCancelsAnImeCompositionInItsAnchor() + { + 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._OnEscapeComposition(true)); + component.Find(".bit-clo").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsTrue(component.Instance.IsOpen); + + component.InvokeAsync(() => component.Instance._OnEscapeComposition(false)); + component.Find(".bit-clo").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsFalse(component.Instance.IsOpen); + } + + [TestMethod] + public void BitCalloutShouldReadTheCompositionOfAnEscapeOnlyOnce() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, Markup("")); + }); + + component.Find(".bit-clo-acn").Click(); + + component.InvokeAsync(() => component.Instance._OnEscapeComposition(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..3a7afdad76b 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,27 @@ public void BitTooltipShouldBeDismissedByEscape() Assert.IsFalse(component.Find(".bit-ttp-wrp").ClassList.Contains("bit-ttp-vis")); } + [TestMethod] + public void BitTooltipShouldNotBeDismissedByAnEscapeThatCancelsAnImeCompositionInItsAnchor() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Text, "Tip"); + parameters.Add(p => p.DefaultIsShown, true); + }); + + // The JS side leaves the key to the field the IME is composing in, and tells the key handler so ahead of it. + component.InvokeAsync(() => component.Instance._OnEscapeComposition(true)); + component.Find(".bit-ttp").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" }); + + Assert.IsTrue(component.Find(".bit-ttp-wrp").ClassList.Contains("bit-ttp-vis")); + + // The answer was given for that one press: the next one dismisses the tooltip as before. + 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 9824427a710..9d0be13987f 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs @@ -90,6 +90,93 @@ public void EverySurfaceShouldLeaveAClaimedEscapeAlone(string method) } } + [TestMethod] + public void TheCompositionOfAnEscapeShouldBeReadInOnePlace() + { + var script = SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"); + var check = GetMethod(script, "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, "e.keyCode === 229"); + + var copies = script.Split('\n') + .Where(line => line.TrimStart().StartsWith("//") is false) + .Where(line => Regex.IsMatch(line, @"\.isComposing\b|=== 229")) + .Where(line => check.Contains(line) is false) + .ToList(); + + Assert.AreEqual(0, copies.Count, $"Utils.ts reads the composition of a key outside Utils.isComposingKey: {string.Join(" | ", copies.Select(l => l.Trim()))}"); + } + + [TestMethod, + DataRow("claimEscape"), + DataRow("setupSurfaceEscape"), + DataRow("setupEscapeGuard"), + DataRow("watchEscape"), + DataRow("watchLayerEscape"), + DataRow("setupEscape"), + DataRow("setupTooltip"), + DataRow("dismissTooltipsOnEscape"), + DataRow("watchEscapeComposition") + ] + 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("setupEscapeGuard"), + 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 TheCalloutShouldBeToldWhichEscapesCancelAComposition() + { + // A BitCallout dismisses itself in .NET, on its root and on its content, where KeyboardEventArgs carries no key + // code (and no composition state before .NET 9), so the browser answers every Escape it is about to hear. + var callouts = SourceFiles.Read("Bit.BlazorUI", "Scripts", "Callouts.ts"); + + StringAssert.Contains(GetMethod(callouts, "setupKeyboard", "Callouts"), "Utils.watchEscapeComposition(calloutId, [compositionRootId, calloutId], dotnetObj)"); + StringAssert.Contains(GetMethod(callouts, "disposeKeyboard", "Callouts"), "Utils.unwatchEscapeComposition(calloutId)"); + StringAssert.Contains(GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), "watchEscapeComposition"), "'OnEscapeComposition'"); + } + + [TestMethod] + public void TheTooltipShouldTellItsKeyHandlerWhichEscapesCancelAComposition() + { + // The tooltip leaves the Escape of a composition to the field it is in, so the key reaches its own .NET handler + // too - the fallback for a press the script has not taken - which has to be told it is not a dismissal. + var tooltip = GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), "setupTooltip"); + + StringAssert.Contains(tooltip, "invokeMethodAsync('OnEscapeComposition', true)"); + } + [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..1fbed21c7bc 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,9 @@ 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. + An Escape cancelling an IME composition is nobody's: the surface (a Callout as well, whose own handlers answer the + key in .NET) and a Tooltip shown in it both stay. *@

Escape Claim Regression (@Surface)

@@ -44,6 +46,10 @@ @Content break; + case "callout": + + break; + default: break; @@ -98,8 +104,12 @@ // 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..bc2b27b545d 100644 --- a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitEscapeClaimBrowserTests.cs +++ b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitEscapeClaimBrowserTests.cs @@ -328,6 +328,68 @@ public async Task Tooltip_AroundAnOpenPopup_GoesAlongAsThePopupClosesAndTheSurfa await Expect(tooltip).ToHaveCountAsync(0); } + // 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}")}"); From 5bc8c51792dd3cc5e723d9506a950f98c01cb28a Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 18:22:11 +0330 Subject: [PATCH 2/2] code review --- .../Components/AppShell/BitAppShell.ts | 2 +- .../Components/Chart/BitChart.ts | 2 +- .../MarkdownEditor/BitMarkdownEditor.ts | 4 +- .../Scripts/BitBlazorUI.d.ts | 1 + .../Inputs/SearchBox/BitSearchBox.ts | 4 +- .../Inputs/TagsInput/BitTagsInput.ts | 4 +- .../Inputs/TextField/BitTextField.ts | 6 +- .../Surfaces/Callout/BitCallout.razor.cs | 83 +++++++++----- .../Surfaces/Tooltip/BitTooltip.razor.cs | 24 +--- .../JsInterop/CalloutsJsRuntimeExtensions.cs | 12 +- src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts | 43 +++++-- src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts | 85 +++++--------- src/BlazorUI/Bit.BlazorUI/Scripts/general.ts | 4 + src/BlazorUI/CLAUDE.md | 10 ++ .../Surfaces/Callout/BitCalloutTests.cs | 66 ++++++++--- .../Surfaces/Tooltip/BitTooltipTests.cs | 14 ++- .../Utils/BitEscapeClaimContractTests.cs | 69 +++++++---- .../Components/Pages/BitEscapeClaim.razor | 28 ++++- .../BitEscapeClaimBrowserTests.cs | 108 +++++++++++++++--- 19 files changed, 376 insertions(+), 193 deletions(-) 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 b3a52219e9a..52d57bbd5d5 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 c3fe7e23bb1..d185aa22ea9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Callout/BitCallout.razor.cs @@ -28,10 +28,10 @@ public partial class BitCallout : BitComponentBase private bool _focusTrapped; private bool _focusOriginCaptured; private bool _escapeRegistered; - private bool _escapeCompositionWatched; - // Whether the Escape the key handlers are about to hear cancels an IME composition, as the browser answered it - // (see _OnEscapeComposition), taken by the handler as it starts. - private bool _composingEscape; + private bool _escapeGuarded; + // What the escape guard answered for the Escape Blazor is about to hand a key handler (see _OnEscapeVerdict), + // taken by the handler as it starts. + private bool _foreignEscape; private bool _tabOutRegistered; private bool _scrollLocked; private bool _hoverInside; @@ -650,13 +650,16 @@ public async Task _OnEscape() await InvokeAsync(StateHasChanged); } - [JSInvokable("OnEscapeComposition")] - public void _OnEscapeComposition(bool composing) + // Sent by the escape guard as each Escape goes down inside the anchor or the callout while it is open, ahead of the + // keydown Blazor then dispatches, so the key handler that runs next reads the answer that was taken for its own key: + // whether the press is another's - one a part inside them claimed (see Utils.claimEscape), one a popup opened from + // inside them closes, one cancelling an IME composition - rather than the callout's (see Utils.setupEscapeGuard). + // It is written for every Escape, so one that never reaches the handler leaves nothing behind that the next one + // could be mistaken for. + [JSInvokable("OnEscapeVerdict")] + public void _OnEscapeVerdict(bool foreign) { - // Sent for every Escape that goes through the root or the callout while it is open, ahead of the keydown - // Blazor dispatches for it (see Utils.watchEscapeComposition): KeyboardEventArgs cannot say whether the key - // cancelled an IME composition - it carries no key code, and no composition state before .NET 9. - _composingEscape = composing; + _foreignEscape = foreign; } [JSInvokable("OnTabOut")] @@ -899,7 +902,7 @@ private async Task HandleOnCalloutClick() // listener leaves the anchor out, so this is the one place the key is answered for it. private async Task HandleOnRootKeyDown(KeyboardEventArgs e) { - if (TakeComposingEscape(e)) return; + if (TakeForeignEscape(e)) return; if (Disabled || IsOpen is false) return; @@ -915,7 +918,7 @@ private async Task HandleOnRootKeyDown(KeyboardEventArgs e) private async Task HandleOnCalloutKeyDown(KeyboardEventArgs e) { - if (TakeComposingEscape(e)) return; + if (TakeForeignEscape(e)) return; if (Disabled || IsOpen is false) return; @@ -932,17 +935,17 @@ private async Task HandleOnCalloutKeyDown(KeyboardEventArgs e) StateHasChanged(); } - // Whether the key is an Escape that cancels what an IME was composing in a field of the anchor or of the callout: - // the press is the composition's, never a dismissal. The browser's answer is read once, so it never outlives the - // press it was given for. - private bool TakeComposingEscape(KeyboardEventArgs e) + // Whether the key is an Escape the escape guard said is another's (see _OnEscapeVerdict): a field of the anchor + // clearing, a dropdown inside the callout closing its list - the press does that, and the callout waits for the + // next one. The answer is read once, so it never outlives the press it was given for. + private bool TakeForeignEscape(KeyboardEventArgs e) { if (e.Key is not "Escape") return false; - var composing = _composingEscape; - _composingEscape = false; + var foreign = _foreignEscape; + _foreignEscape = false; - return composing; + return foreign; } private async Task HandleOnMouseEnter() @@ -1351,8 +1354,9 @@ private async Task ReturnFocus(bool restore) // - The element the focus is on is remembered, by a callout without an anchor of its own - an external one, // or a point: the one it renders is otherwise where the focus goes back to. // - Escape is listened for on the page (see ListensForEscapeOnPage). - // - The Escapes the key handlers of the root and of the callout hear are told apart from the ones cancelling an - // IME composition (see _OnEscapeComposition). + // - The anchor and the callout the key handlers dismiss it from on Escape are guarded, so they leave alone a press + // that is another's (see _OnEscapeVerdict): only while it dismisses on Escape at all, and the callout only while + // the page's listener is not the one answering the keys pressed inside it. // - Tab and Shift+Tab are kept inside a callout that keeps the focus, or the content of one that does not is // put into the tab order right after its trigger (see GetTabOutTriggerId). private async Task SetupKeyboard() @@ -1361,21 +1365,21 @@ private async Task SetupKeyboard() var captureFocusOrigin = HasOwnAnchor is false && _focusOriginCaptured is false; var escape = ListensForEscapeOnPage && _escapeRegistered is false; - var escapeComposition = _escapeCompositionWatched is false; + var escapeGuardIds = _escapeGuarded ? null : GetEscapeGuardIds(); var focusTrap = KeepsFocus && _focusTrapped is false; var tabOutTriggerId = _tabOutRegistered ? null : GetTabOutTriggerId(); - if ((captureFocusOrigin || escape || escapeComposition || focusTrap || tabOutTriggerId is not null) is false) return; + if ((captureFocusOrigin || escape || escapeGuardIds is not null || focusTrap || tabOutTriggerId is not null) is false) return; _escapeRegistered |= escape; - _escapeCompositionWatched |= escapeComposition; + _escapeGuarded |= escapeGuardIds is not null; _focusTrapped |= focusTrap; _tabOutRegistered |= tabOutTriggerId is not null; try { var captured = await _js.BitCalloutSetupKeyboard(_contentId, _dotnetObj, captureFocusOrigin, escape, _Id, focusTrap, tabOutTriggerId, - escapeComposition ? _Id : null); + escapeGuardIds); // A focus that was on the body leaves nothing to hand back to, which ReturnFocus has to know to fall // back to the anchor instead. @@ -1393,11 +1397,11 @@ private async Task DisposeKeyboard(bool forgetFocusOrigin) { var forget = forgetFocusOrigin && _focusOriginCaptured; - if ((_escapeRegistered || _escapeCompositionWatched || _focusTrapped || _tabOutRegistered || forget) is false) return; + if ((_escapeRegistered || _escapeGuarded || _focusTrapped || _tabOutRegistered || forget) is false) return; _escapeRegistered = false; - _escapeCompositionWatched = false; - _composingEscape = false; + _escapeGuarded = false; + _foreignEscape = false; _focusTrapped = false; _tabOutRegistered = false; @@ -1628,6 +1632,29 @@ private async Task SetupTabOut() return Anchor is not null ? _anchorId : AnchorId.HasValue() ? AnchorId : null; } + // The elements whose key handlers dismiss the callout on Escape, guarded so that they leave alone a press that is + // another's (see _OnEscapeVerdict), or null for none: the root while it holds an anchor, and the callout while the + // page's listener is not the one answering the keys pressed inside it (see HandleOnCalloutKeyDown). A callout that + // does not dismiss on Escape has nothing to be told, and no round trip is made for its keys. + private string[]? GetEscapeGuardIds() + { + if (NoDismissOnEscape) return null; + + List ids = []; + + if (Anchor is not null) + { + ids.Add(_Id); + } + + if (ListensForEscapeOnPage is false) + { + ids.Add(_contentId); + } + + return ids.Count > 0 ? [.. ids] : null; + } + private async Task DisposeTabOut() { if (_tabOutRegistered is false) return; 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 63dac407fac..a071a640594 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Tooltip/BitTooltip.razor.cs @@ -59,10 +59,6 @@ public partial class BitTooltip : BitComponentBase // two apart for a tooltip a click opened: a Tab away dismisses it, a press on its own text does not. private bool _isPressWithin; - // Whether the Escape the key handler is about to hear cancels an IME composition in a field of the anchor, as the - // browser answered it (see _OnEscapeComposition), taken by the handler as it starts. - private bool _composingEscape; - // The relationship last mirrored onto the anchor, so that the round trip to the DOM is made when what // it would write has actually changed rather than once per render. private string _syncedAria = string.Empty; @@ -582,17 +578,6 @@ public async Task _OnEscape() await DismissByEscape(); } - /// - /// An Escape pressed in the anchor to cancel what an IME was composing, which the JS side leaves to the field and - /// tells the key handler about ahead of the keydown Blazor dispatches for it: KeyboardEventArgs cannot say so - it - /// carries no key code, and no composition state before .NET 9. - /// - [JSInvokable("OnEscapeComposition")] - public void _OnEscapeComposition(bool composing) - { - _composingEscape = composing; - } - /// /// A press the JS side saw land outside the component while a press of the anchor held the tooltip open. /// Safari and every touch browser leave a pressed button unfocused, so the focus leaving it - which is what @@ -930,14 +915,11 @@ 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. - // A key cancelling an IME composition, which it lets through as well, is the composition's alone; that answer - // is read once, so it never outlives the press it was given for. + // 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") { - var composing = _composingEscape; - _composingEscape = false; - - if (composing) return; + if (e.IsPlainEscape() is false) return; await DismissByEscape(); diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs index 32f71061b1b..adb8a764f1d 100644 --- a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalloutsJsRuntimeExtensions.cs @@ -135,10 +135,10 @@ internal static ValueTask BitCalloutClearCallout(this IJSRuntime jsRuntime, stri } // Hands the keyboard over to an opening BitCallout in one round trip: remembers the element the focus is on, - // listens for Escape on the page, tells the Escapes of an IME composition apart on the root named by - // compositionRootId and on the callout, traps the focus, or puts the callout in the tab order after its - // trigger - each only where asked for (a null id asks for no composition watch, a null trigger id for no - // tab-out). Returns whether a focus origin was remembered, which it is not for a focus that was on the body. + // listens for Escape on the page, guards the elements named by escapeGuardIds (Utils.setupEscapeGuard), traps + // the focus, or puts the callout in the tab order after its trigger - each only where asked for (null ids ask + // for no guard, a null trigger id for no tab-out). Returns whether a focus origin was remembered, which it is + // not for a focus that was on the body. internal static ValueTask BitCalloutSetupKeyboard<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( this IJSRuntime jsRuntime, string calloutId, @@ -148,10 +148,10 @@ internal static ValueTask BitCalloutClearCallout(this IJSRuntime jsRuntime, stri string? escapeTriggerId, bool focusTrap, string? tabOutTriggerId, - string? compositionRootId) where T : class + string[]? escapeGuardIds) where T : class { return jsRuntime.Invoke("BitBlazorUI.Callouts.setupKeyboard", - calloutId, dotnetObj, captureFocusOrigin, escape, escapeTriggerId, focusTrap, tabOutTriggerId, compositionRootId); + calloutId, dotnetObj, captureFocusOrigin, escape, escapeTriggerId, focusTrap, tabOutTriggerId, escapeGuardIds); } // Takes back everything BitCalloutSetupKeyboard may have set up, in one round trip. diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts index 06172cf5234..0fd24a6230e 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts @@ -1684,11 +1684,12 @@ // Hands the keyboard over to an opening BitCallout in one call rather than one per piece, since each // is a round trip of its own on a server-rendered page and all of them stand between the click and the // focus moving into the callout: the element the focus is on is remembered (to be handed back to on - // close), Escape is listened for on the page, the Escapes the component's own handlers hear on the root - // and on the callout are told apart from the ones cancelling an IME composition, Tab and Shift+Tab are - // kept inside the callout, or the callout is put into the tab order after its trigger. Each is asked for - // on its own, and the ones the component has already set up are not asked for again. Reports whether a - // focus origin was remembered. + // close), Escape is listened for on the page, the elements the component's own .NET handlers dismiss it + // from on Escape are guarded (see Utils.setupEscapeGuard), so that those handlers leave alone a press that + // a part inside them claimed or a popup opened from inside them closes, Tab and Shift+Tab are kept inside + // the callout, or the callout is put into the tab order after its trigger. Each is asked for on its own, + // and the ones the component has already set up are not asked for again. Reports whether a focus origin + // was remembered. public static setupKeyboard( calloutId: string, dotnetObj: DotNetObject, @@ -1697,15 +1698,23 @@ escapeTriggerId: string | null, focusTrap: boolean, tabOutTriggerId: string | null, - compositionRootId: string | null) { + escapeGuardIds: string[] | null) { const captured = captureFocusOrigin && Utils.captureFocusOrigin(calloutId); - if (escape) { - Utils.setupEscape(calloutId, dotnetObj, escapeTriggerId); + // Ahead of the page's Escape listener, whose registration of the callout as a surface a guard taken back + // here would forget along with its own. + if (escapeGuardIds?.length) { + Callouts.disposeEscapeGuards(calloutId); + + for (const id of escapeGuardIds) { + Utils.setupEscapeGuard(id, dotnetObj, calloutId); + } + + Callouts._escapeGuardIds.set(calloutId, escapeGuardIds); } - if (compositionRootId) { - Utils.watchEscapeComposition(calloutId, [compositionRootId, calloutId], dotnetObj); + if (escape) { + Utils.setupEscape(calloutId, dotnetObj, escapeTriggerId); } if (focusTrap) { @@ -1726,13 +1735,25 @@ Utils.disposeFocusTrap(calloutId); Utils.disposeTabOut(calloutId); Utils.disposeEscape(calloutId); - Utils.unwatchEscapeComposition(calloutId); + Callouts.disposeEscapeGuards(calloutId); if (forgetFocusOrigin) { Utils.disposeFocusOrigin(calloutId); } } + // The elements setupKeyboard guarded for each callout, by the id of the callout, so that the disposal takes + // them all back without being told which they were. + private static _escapeGuardIds = new Map(); + + private static disposeEscapeGuards(calloutId: string) { + const ids = Callouts._escapeGuardIds.get(calloutId); + if (!ids) return; + + ids.forEach(id => Utils.disposeEscapeGuard(id)); + Callouts._escapeGuardIds.delete(calloutId); + } + // Whether the callout about to be opened belongs to a component that sits inside the innermost open // callout. The component is measured where it is: only the callout is ever relocated to the body. private static isNestedInTop(callout: BitCallout): boolean { diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts index f2918ce0ddf..bf4d457deae 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts @@ -350,20 +350,12 @@ const controller = new AbortController(); root.addEventListener('keydown', e => { - if (e.key !== 'Escape') return; - - // A press cancelling an IME composition in a field of the anchor is the composition's: it goes on to - // the field and the page untaken, and the tooltip's own .NET key handler, which hears it on the way and - // cannot tell it apart itself, is told so ahead of it. - if (Utils.isComposingKey(e)) { - dotnetObj.invokeMethodAsync('OnEscapeComposition', true) - .catch(err => console.error("BitBlazorUI.Utils.setupTooltip:", err)); - return; - } - // 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.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + 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) { @@ -743,8 +735,22 @@ // 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) { - return e.isComposing || e.keyCode === 229; + 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(); @@ -1078,40 +1084,6 @@ Utils.forgetEscapeSurface(elementId); } - private static _escapeCompositionWatches = new Map(); - - // Tells a component whose .NET keydown handlers dismiss it on Escape (a callout, on its anchor and on its - // content) whether the Escape a handler is about to hear was pressed to cancel what an input method editor was - // composing (see isComposingKey) - which .NET cannot read for itself: KeyboardEventArgs carries no key code, - // and no composition state before .NET 9. Every Escape is answered, through the OnEscapeComposition callback, - // so an answer never outlives the press it was given for. The listeners are on the elements the handlers are - // on, so they run before Blazor's document-level delegation, and the answer is queued ahead of the keydown - // Blazor dispatches right after it. - public static watchEscapeComposition(id: string, elementIds: string[], dotnetObj: DotNetObject) { - Utils.unwatchEscapeComposition(id); - - const controller = new AbortController(); - - for (const elementId of elementIds) { - document.getElementById(elementId)?.addEventListener('keydown', e => { - if (e.key !== 'Escape') return; - - dotnetObj.invokeMethodAsync('OnEscapeComposition', Utils.isComposingKey(e)) - .catch(err => console.error("BitBlazorUI.Utils.watchEscapeComposition:", err)); - }, { signal: controller.signal }); - } - - Utils._escapeCompositionWatches.set(id, controller); - } - - public static unwatchEscapeComposition(id: string) { - const controller = Utils._escapeCompositionWatches.get(id); - if (!controller) return; - - controller.abort(); - Utils._escapeCompositionWatches.delete(id); - } - // The callouts that answer an Escape pressed anywhere in the page (setupEscape with a trigger), not only // one pressed inside them or on their trigger. private static _escapesFromAnywhere = new Set(); @@ -1131,12 +1103,15 @@ // - a handler that already prevented the key's default; // - an IME composition the key cancels; // - a guarded surface nested inside this one, which answers the key itself (or refuses it). + // A callout guards the anchor and the content its own handlers dismiss it from (Callouts.setupKeyboard), and + // names itself as `calloutId`: it is the innermost open callout the key closes then, which is never foreign to + // it - only another one, opened from a component inside it, is. // The answer has to be taken while the key is still going down: this listener is on the element, so it // runs before Blazor's document-level delegation lets the nested component close its popup. It is sent // for every Escape rather than read back later, so each keydown carries its own answer: the callback is // queued ahead of the keydown Blazor dispatches right after it, and one Escape can never be answered // with what was recorded for the next - however slow the connection. - public static setupEscapeGuard(elementId: string, dotnetObj: DotNetObject) { + public static setupEscapeGuard(elementId: string, dotnetObj: DotNetObject, calloutId?: string | null) { Utils.disposeEscapeGuard(elementId); const element = document.getElementById(elementId); @@ -1152,7 +1127,7 @@ const foreign = Utils._claimedEscapes.has(e) || e.defaultPrevented || Utils.isComposingKey(e) - || Utils.isEscapeOfOpenCallout(e.target); + || Utils.isEscapeOfOpenCallout(e.target, calloutId); // A closed surface is inert, so an Escape reaches it only while it is open - and from then on the // key is this surface's to answer or to refuse, never the one's it was opened from. @@ -1160,7 +1135,8 @@ Utils._claimedEscapes.add(e); } - dotnetObj.invokeMethodAsync('OnEscapeVerdict', foreign); + dotnetObj.invokeMethodAsync('OnEscapeVerdict', foreign) + .catch(err => console.error("BitBlazorUI.Utils.setupEscapeGuard:", err)); }, { signal: controller.signal }); Utils._escapeGuards.set(elementId, controller); @@ -1178,10 +1154,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 setupEscapeGuard) 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; 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 a8dd75ce85b..82372ba04c3 100644 --- a/src/BlazorUI/CLAUDE.md +++ b/src/BlazorUI/CLAUDE.md @@ -92,6 +92,16 @@ the callouts, BitTooltip, BitMessage - the listeners in `Scripts/Utils.ts`) leav - **One shared watch per claiming root.** A component whose own .NET keydown on its root acts on the key it claims there hears the presses a part inside it claimed first, too; it tells them apart through `BitEscapeClaimWatch` (`Utils.watchEscapeClaim`), never a copy of it. +- **A surface that dismisses itself in its own .NET keydown handler asks the escape guard first.** `BitPanel` and + `BitCallout` (its anchor and its content) go by `Utils.setupEscapeGuard`'s per-press `OnEscapeVerdict`, which + folds a claim, a prevented default, an open popup opened from inside and an IME composition into one answer - + never a channel of their own for one of them. A callout names itself to the guard, 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 ea3251ed12b..61238fe50b5 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 @@ -814,7 +814,7 @@ public void BitCalloutShouldIgnoreEscapeFromTheAnchorWhileItIsClosed() } [TestMethod] - public void BitCalloutShouldWatchItsRootAndItselfForTheEscapesOfAnImeCompositionAsItOpens() + public void BitCalloutShouldGuardItsRootAndItselfAsItOpens() { var component = RenderComponent(parameters => { @@ -823,16 +823,55 @@ public void BitCalloutShouldWatchItsRootAndItselfForTheEscapesOfAnImeComposition component.Find(".bit-clo-acn").Click(); - // KeyboardEventArgs cannot say whether an Escape cancelled what an IME was composing, so the browser tells the - // key handlers of the root and of the callout, in the same round trip as the rest of the keyboard. + // The key handlers of the root and of the callout hear the presses a part inside them claimed, or a popup + // opened from inside them closes, as well, so the browser answers each Escape - in the same round trip as the + // rest of the keyboard. var setup = Context.JSInterop.Invocations["BitBlazorUI.Callouts.setupKeyboard"][^1].Arguments; Assert.AreEqual(component.Find(".bit-clo-cal").Id, setup[0]); - Assert.AreEqual(component.Find(".bit-clo").Id, setup[7]); + CollectionAssert.AreEqual(new[] { component.Find(".bit-clo").Id, component.Find(".bit-clo-cal").Id }, (string[])setup[7]!); } [TestMethod] - public void BitCalloutShouldNotCloseOnAnEscapeThatCancelsAnImeCompositionInsideIt() + public void BitCalloutThatListensForEscapeOnThePageShouldGuardOnlyItsRoot() + { + Context.JSInterop.Setup("BitBlazorUI.Utils.isHoverDevice").SetResult(true); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OpenOnHover, true); + parameters.Add(p => p.Anchor, Markup("")); + }); + + component.Find(".bit-clo").MouseEnter(); + + Assert.IsTrue(component.Instance.IsOpen); + + // The page's listener answers the keys pressed inside the callout, and the handler there leaves them to it. + var setup = Context.JSInterop.Invocations["BitBlazorUI.Callouts.setupKeyboard"][^1].Arguments; + + CollectionAssert.AreEqual(new[] { component.Find(".bit-clo").Id }, (string[])setup[7]!); + } + + [TestMethod] + public void BitCalloutThatDoesNotDismissOnEscapeShouldGuardNothing() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, Markup("")); + parameters.Add(p => p.NoDismissOnEscape, true); + }); + + component.Find(".bit-clo-acn").Click(); + + // Nothing would act on the answer, so no Escape costs a round trip. + var setup = Context.JSInterop.Invocations["BitBlazorUI.Callouts.setupKeyboard"][^1].Arguments; + + Assert.IsNull(setup[7]); + } + + [TestMethod] + public void BitCalloutShouldNotCloseOnAnEscapeAnotherOwnsInsideIt() { var component = RenderComponent(parameters => { @@ -841,20 +880,21 @@ public void BitCalloutShouldNotCloseOnAnEscapeThatCancelsAnImeCompositionInsideI component.Find(".bit-clo-acn").Click(); - component.InvokeAsync(() => component.Instance._OnEscapeComposition(true)); + // 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 composition has ended, and the next press is the callout's. - component.InvokeAsync(() => component.Instance._OnEscapeComposition(false)); + // 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 BitCalloutShouldNotCloseOnAnEscapeThatCancelsAnImeCompositionInItsAnchor() + public void BitCalloutShouldNotCloseOnAnEscapeAnotherOwnsInItsAnchor() { var component = RenderComponent(parameters => { @@ -864,19 +904,19 @@ public void BitCalloutShouldNotCloseOnAnEscapeThatCancelsAnImeCompositionInItsAn 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._OnEscapeComposition(true)); + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(true)); component.Find(".bit-clo").KeyDown(new KeyboardEventArgs { Key = "Escape" }); Assert.IsTrue(component.Instance.IsOpen); - component.InvokeAsync(() => component.Instance._OnEscapeComposition(false)); + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(false)); component.Find(".bit-clo").KeyDown(new KeyboardEventArgs { Key = "Escape" }); Assert.IsFalse(component.Instance.IsOpen); } [TestMethod] - public void BitCalloutShouldReadTheCompositionOfAnEscapeOnlyOnce() + public void BitCalloutShouldReadTheVerdictOfAnEscapeOnlyOnce() { var component = RenderComponent(parameters => { @@ -885,7 +925,7 @@ public void BitCalloutShouldReadTheCompositionOfAnEscapeOnlyOnce() component.Find(".bit-clo-acn").Click(); - component.InvokeAsync(() => component.Instance._OnEscapeComposition(true)); + component.InvokeAsync(() => component.Instance._OnEscapeVerdict(true)); component.Find(".bit-clo-cal").KeyDown(new KeyboardEventArgs { Key = "Escape" }); Assert.IsTrue(component.Instance.IsOpen); 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 3a7afdad76b..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,8 +627,12 @@ public void BitTooltipShouldBeDismissedByEscape() Assert.IsFalse(component.Find(".bit-ttp-wrp").ClassList.Contains("bit-ttp-vis")); } - [TestMethod] - public void BitTooltipShouldNotBeDismissedByAnEscapeThatCancelsAnImeCompositionInItsAnchor() + [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 => { @@ -636,13 +640,11 @@ public void BitTooltipShouldNotBeDismissedByAnEscapeThatCancelsAnImeCompositionI parameters.Add(p => p.DefaultIsShown, true); }); - // The JS side leaves the key to the field the IME is composing in, and tells the key handler so ahead of it. - component.InvokeAsync(() => component.Instance._OnEscapeComposition(true)); - component.Find(".bit-ttp").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" }); + // 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")); - // The answer was given for that one press: the next one dismisses the tooltip as before. component.Find(".bit-ttp").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" }); Assert.IsFalse(component.Find(".bit-ttp-wrp").ClassList.Contains("bit-ttp-vis")); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs index 9d0be13987f..07927b214eb 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/BitEscapeClaimContractTests.cs @@ -91,23 +91,36 @@ public void EverySurfaceShouldLeaveAClaimedEscapeAlone(string method) } [TestMethod] - public void TheCompositionOfAnEscapeShouldBeReadInOnePlace() + public void TheCompositionOfAKeyShouldBeReadInOnePlace() { - var script = SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"); - var check = GetMethod(script, "isComposingKey"); + 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, "e.keyCode === 229"); + 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 = script.Split('\n') - .Where(line => line.TrimStart().StartsWith("//") is false) - .Where(line => Regex.IsMatch(line, @"\.isComposing\b|=== 229")) - .Where(line => check.Contains(line) is false) - .ToList(); + 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, $"Utils.ts reads the composition of a key outside Utils.isComposingKey: {string.Join(" | ", copies.Select(l => l.Trim()))}"); + Assert.AreEqual(0, copies.Count, $"A script reads the composition of a key outside Utils.isComposingKey: {string.Join(" | ", copies)}"); } [TestMethod, @@ -119,7 +132,7 @@ public void TheCompositionOfAnEscapeShouldBeReadInOnePlace() DataRow("setupEscape"), DataRow("setupTooltip"), DataRow("dismissTooltipsOnEscape"), - DataRow("watchEscapeComposition") + DataRow("stopComposingEscape") ] public void EverySurfaceShouldLeaveTheEscapeOfAnImeCompositionAlone(string method) { @@ -156,25 +169,35 @@ public void EveryListenerThatAnswersTheKeyShouldReadTheCompositionItself(string } [TestMethod] - public void TheCalloutShouldBeToldWhichEscapesCancelAComposition() + public void TheEscapeOfAnImeCompositionShouldNeverReachDotNet() { - // A BitCallout dismisses itself in .NET, on its root and on its content, where KeyboardEventArgs carries no key - // code (and no composition state before .NET 9), so the browser answers every Escape it is about to hear. - var callouts = SourceFiles.Read("Bit.BlazorUI", "Scripts", "Callouts.ts"); + // 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));"); - StringAssert.Contains(GetMethod(callouts, "setupKeyboard", "Callouts"), "Utils.watchEscapeComposition(calloutId, [compositionRootId, calloutId], dotnetObj)"); - StringAssert.Contains(GetMethod(callouts, "disposeKeyboard", "Callouts"), "Utils.unwatchEscapeComposition(calloutId)"); - StringAssert.Contains(GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), "watchEscapeComposition"), "'OnEscapeComposition'"); + 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 TheTooltipShouldTellItsKeyHandlerWhichEscapesCancelAComposition() + public void TheCalloutShouldGuardTheElementsItsOwnHandlersDismissItFrom() { - // The tooltip leaves the Escape of a composition to the field it is in, so the key reaches its own .NET handler - // too - the fallback for a press the script has not taken - which has to be told it is not a dismissal. - var tooltip = GetMethod(SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"), "setupTooltip"); + // A BitCallout dismisses itself in .NET, on its root and on its content, which hear a press a part inside them + // claimed and one a popup opened from inside them closes, too - so each is guarded the way a panel is, and the + // guard is told which callout is asking: that one is the innermost open callout the key closes, never another. + var callouts = SourceFiles.Read("Bit.BlazorUI", "Scripts", "Callouts.ts"); + var utils = SourceFiles.Read("Bit.BlazorUI", "Scripts", "Utils.ts"); - StringAssert.Contains(tooltip, "invokeMethodAsync('OnEscapeComposition', true)"); + StringAssert.Contains(GetMethod(callouts, "setupKeyboard", "Callouts"), "Utils.setupEscapeGuard(id, dotnetObj, calloutId)"); + StringAssert.Contains(GetMethod(callouts, "disposeKeyboard", "Callouts"), "Callouts.disposeEscapeGuards(calloutId)"); + StringAssert.Contains(GetMethod(utils, "setupEscapeGuard"), "Utils.isEscapeOfOpenCallout(e.target, calloutId)"); + StringAssert.Contains(GetMethod(utils, "isEscapeOfOpenCallout"), "current.calloutId === ownCalloutId"); } [TestMethod, 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 1fbed21c7bc..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 @@ -9,8 +9,10 @@ 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. - An Escape cancelling an IME composition is nobody's: the surface (a Callout as well, whose own handlers answer the - key in .NET) and a Tooltip shown in it both stay. *@ + 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)

@@ -25,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

@@ -73,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 { @@ -98,6 +119,9 @@ 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 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 bc2b27b545d..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,78 @@ 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.