Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -649,14 +649,15 @@ public async Task _OnEscape()
}

// Sent by the JS side as an Escape goes down inside the root or the callout while it is open, ahead of the keydown
// Blazor then dispatches, saying whether it was pressed inside a surface nested there - another callout's anchor, a
// dialog, a modal, a panel - that has spoken for it first (see Utils.watchNestedEscape, which Callouts.toggle sets
// up with the open). Only those presses are answered, and the one after them, so one that never reaches a handler
// is overwritten before the next one is read (BitEscapeVerdict).
// Blazor then dispatches, saying whether the press is another's - a surface nested there that has spoken for it
// first (another callout's anchor, a dialog, a modal, a panel), a part inside them that claimed it (see
// Utils.claimEscape), a popup opened from inside them that closes on it - rather than the callout's (see
// Utils.watchNestedEscape, which Callouts.toggle sets up with the open). Only those presses are answered, and the
// one after them, so one that never reaches a handler is overwritten before the next one is read (BitEscapeVerdict).
[JSInvokable("OnEscapeVerdict")]
public void _OnEscapeVerdict(bool nested)
public void _OnEscapeVerdict(bool foreign)
{
_escapeVerdict.Set(nested);
_escapeVerdict.Set(foreign);
}

[JSInvokable("OnTabOut")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -915,8 +915,12 @@ private async Task HandleKeyDown(KeyboardEventArgs e)
// The JS side takes an Escape that dismisses a shown tooltip before it reaches this handler, so this is
// only the answer while that side is not set up yet - and a repeat of it, which finds the tooltip
// already hidden, for a key it let through to a component inside the anchor that answers Escape itself.
// Only the plain key, as on the JS side: an Escape with a modifier goes on to the surface around the tooltip.
// A key cancelling an IME composition never gets here (see Utils.stopComposingEscape).
if (e.Key is "Escape")
{
if (e.IsPlainEscape() is false) return;

await DismissByEscape();

return;
Expand Down
64 changes: 50 additions & 14 deletions src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@
// Only the plain key: an Escape with a modifier goes on to the surface around the tooltip, as it
// does past every component that acts on Escape (claimEscape).
if (e.key !== 'Escape' || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
// A press cancelling an IME composition in a field of the anchor is the composition's: it goes on to the
// field untaken, and no further than the page's own elements (see stopComposingEscape).
if (Utils.isComposingKey(e)) return;
if (!root.querySelector(':scope > .bit-ttp-wrp.bit-ttp-vis[data-bit-ttp-esc]')) return;

if (e.defaultPrevented) {
Expand Down Expand Up @@ -417,6 +420,8 @@
// on to the component rather than being stopped here.
if (e.key !== 'Escape' || e.defaultPrevented) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
// A press cancelling an IME composition is the composition's, wherever the pointer rests.
if (Utils.isComposingKey(e)) return;

if (Utils._tooltipsByRoot.size === 0) return;

Expand Down Expand Up @@ -701,7 +706,7 @@
// so the surface around them closes on it.
public static claimEscape(e: KeyboardEvent) {
if (e.key !== 'Escape' || e.defaultPrevented) return;
if (e.isComposing || e.keyCode === 229) return;
if (Utils.isComposingKey(e)) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;

const target = e.target instanceof Element ? e.target : null;
Expand All @@ -728,6 +733,29 @@
&& !!element.querySelector(':scope > .bit-ttp-wrp.bit-ttp-vis[data-bit-ttp-esc]');
}

// Whether the key was pressed while an input method editor (Pinyin, Kana, Hangul, ...) was composing, where
// Escape cancels the candidate rather than meaning anything to the page: such a press is nobody's, and every
// listener that closes something on Escape leaves it alone. Most engines report those keydowns as the
// "Process" key, which no listener is looking for anyway; the ones that report the real key set isComposing,
// and the ones that predate it report the legacy 229 key code instead.
public static isComposingKey(e: KeyboardEvent | InputEvent) {
return e.isComposing || (e as KeyboardEvent).keyCode === 229;
}

// Keeps an Escape that cancels an IME composition (see isComposingKey) away from .NET. KeyboardEventArgs cannot
// tell such a press from any other - it carries no key code, and no composition state before .NET 9 - and the
// engines that report the real key for it (WebKit) would have every .NET handler that acts on Escape - a
// dropdown's search box, a picker's field, a callout around a field of the consumer's own - act on the press
// the user made to put a candidate back. general.ts calls it on the document element in the bubble phase, so
// every listener of the page's own elements has heard the key by then - the field's, the surfaces', which
// leave it alone themselves - and only Blazor's delegation on the document, and the listeners after it, do
// not: such a press then reaches .NET in no engine, where the others report it as the "Process" key anyway.
public static stopComposingEscape(e: KeyboardEvent) {
if (e.key !== 'Escape' || !Utils.isComposingKey(e)) return;

e.stopPropagation();
}

private static _escapeClaimers = new WeakMap<Event, Element>();

// The element an Escape was claimed for (see claimEscape), if any. A claimer whose .NET handler hears every
Expand Down Expand Up @@ -823,7 +851,7 @@
(e as KeyboardEvent & { __bitSurfaceEscape?: boolean }).__bitSurfaceEscape = true;
if (nested) return;

if (e.isComposing || e.keyCode === 229 || e.defaultPrevented) return;
if (Utils.isComposingKey(e) || e.defaultPrevented) return;

if (Callouts.componentContains(e.target as Node | null, element)) return;

Expand Down Expand Up @@ -1014,6 +1042,9 @@
element.addEventListener('keydown', e => {
if (e.key !== 'Escape' || e.defaultPrevented) return;

// An IME cancelling what it was composing is not dismissing the callout the field is in.
if (Utils.isComposingKey(e)) return;

if (Callouts.current.calloutId !== elementId) return;

// A dialog, a panel or a modal in the content answered the key, and is not one of the open callouts.
Expand All @@ -1029,8 +1060,9 @@
// open callouts is read before Blazor's document-level delegation lets a popup the key belongs to
// (a dropdown list relocated to the body, holding the focus in its search box) close itself.
document.addEventListener('keydown', e => {
// A press a component claimed (see claimEscape) is that component's, wherever its focus is.
if (e.key !== 'Escape' || e.defaultPrevented) return;
// A press a component claimed (see claimEscape) is that component's, wherever its focus is, and
// one cancelling an IME composition is the composition's.
if (e.key !== 'Escape' || e.defaultPrevented || Utils.isComposingKey(e)) return;

if (Callouts.current.calloutId !== elementId) return;

Expand Down Expand Up @@ -1117,10 +1149,11 @@

// Records a surface whose .NET keydown handlers act on Escape as the owner of the Escapes pressed inside its
// elements (see _escapeSurfaceOwners), and tells those handlers, through its OnEscapeVerdict callback, which ones
// are somebody else's: a surface nested inside it owns them, or a component inside it claimed them (see
// are somebody else's: a surface nested inside it owns them, a component inside it claimed them (see
// claimEscape) - another callout's anchor among them, which claims its Escape from the render that opens it,
// before its own watch is in place. A BitCallout watches its root (the anchor) and the callout while it is open
// (Callouts.toggle).
// before its own watch is in place - or a popup opened from inside it closes on them (a dropdown whose list is
// open while the focus is still on its field, see isEscapeOfOpenCallout, which `id` names the watching callout
// to). A BitCallout watches its root (the anchor) and the callout while it is open (Callouts.toggle).
public static watchNestedEscape(id: string, elementIds: string[], dotnetObj: DotNetObject) {
Utils.unwatchNestedEscape(id);

Expand All @@ -1133,7 +1166,9 @@
const nested = Utils.claimSurfaceEscape(e, element);
const claimer = Utils.escapeClaimer(e);

return nested || (!!claimer && claimer !== element && element.contains(claimer));
return nested
|| (!!claimer && claimer !== element && element.contains(claimer))
|| Utils.isEscapeOfOpenCallout(e.target, id);
});

Utils._nestedEscapeWatches.set(id, controller);
Expand Down Expand Up @@ -1187,7 +1222,7 @@
Utils.sendEscapeVerdicts([element], dotnetObj, controller.signal, 'setupEscapeGuard', e => {
const foreign = Utils.isNestedSurfaceEscape(e, element)
|| e.defaultPrevented
|| e.isComposing || e.keyCode === 229 // the engines that predate isComposing report 229
|| Utils.isComposingKey(e)
|| Utils.isEscapeOfOpenCallout(e.target);

// A closed surface is inert, so an Escape reaches it only while it is open - and from then on the
Expand All @@ -1214,10 +1249,11 @@
// Whether the innermost open callout is the one the key closes: it was pressed inside the callout or on
// the component that opened it, or the callout answers an Escape from anywhere (one opened by hovering).
// A callout that is only left open - one the focus has moved away from - does not close on an Escape
// pressed somewhere else, so it does not take that Escape away from the surface either.
private static isEscapeOfOpenCallout(target: EventTarget | null) {
// pressed somewhere else, so it does not take that Escape away from the surface either. The callout that is
// asking (`ownCalloutId`, see watchNestedEscape) is never one the key belongs to instead of it.
private static isEscapeOfOpenCallout(target: EventTarget | null, ownCalloutId?: string | null) {
const current = Callouts.current;
if (!current.calloutId) return false;
if (!current.calloutId || current.calloutId === ownCalloutId) return false;

if (Utils._escapesFromAnywhere.has(current.calloutId)) return true;

Expand Down Expand Up @@ -1265,7 +1301,7 @@
element.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;

if (e.isComposing || Callouts.isOpenedFrom(element)) {
if (Utils.isComposingKey(e) || Callouts.isOpenedFrom(element)) {
claimed.add(e);
}
}, { signal: controller.signal, capture: true });
Expand Down Expand Up @@ -1333,7 +1369,7 @@
window.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;

if (e.isComposing || Callouts.current.calloutId) {
if (Utils.isComposingKey(e) || Callouts.current.calloutId) {
claimed.add(e);
}
}, { signal: controller.signal, capture: true });
Expand Down
4 changes: 4 additions & 0 deletions src/BlazorUI/Bit.BlazorUI/Scripts/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading