// Shared helper for global key listeners to decide whether a keyboard event
// originated from a typing context (input, textarea, select, contentEditable).
//
// Checking `document.activeElement` / `event.target` is NOT enough: inside a
// shadow root both are retargeted to the host element, so the QuotedHtml
// island's inner contentEditable (components/email/quoted-html.ts) looks like
// a plain
from outside. Single-key mailbox shortcuts then fire while the
// user is editing quoted text — up to and including deleting the open email on
// Backspace (#654). `composedPath()` sees through the shadow boundary and
// starts at the real inner target, so it is the reliable signal.
export function isEditableEventTarget(event: Event): boolean {
const path =
typeof event.composedPath === "function"
? event.composedPath()
: [event.target];
return path.some((node) => {
if (!(node instanceof HTMLElement)) return false;
const tag = node.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
if (node.isContentEditable) return true;
// jsdom (tests) doesn't implement isContentEditable; browsers reflect the
// property into the attribute, so this also covers "plaintext-only".
const attr = node.getAttribute("contenteditable");
return attr === "" || attr === "true" || attr === "plaintext-only";
});
}
/**
* Whether a key event comes from a control that Enter or Space activates
* itself (a link, a button): a page-wide shortcut on those keys must leave it
* alone, or a focused "Skip to message list" link opens a message instead.
*/
export function isActivatableEventTarget(event: Event): boolean {
const path =
typeof event.composedPath === "function"
? event.composedPath()
: [event.target];
return path.some(
(node) =>
node instanceof HTMLElement &&
node.matches('a[href], button, summary, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="option"], [role="tab"], [role="switch"]'),
);
}
// Open layers that own the keyboard while they're up: a modal dialog, a menu,
// and the popup of a menu button, listbox button or combobox. A permanent
// listbox or grid (a list on the page) is not one of them.
const KEYBOARD_LAYER_SELECTOR = [
'[aria-modal="true"]',
'[role="menu"]',
'[role="combobox"][aria-expanded="true"]',
'[aria-haspopup="menu"][aria-expanded="true"]',
'[aria-haspopup="listbox"][aria-expanded="true"]',
'[aria-haspopup="true"][aria-expanded="true"]',
].join(", ");
/** Whether a modal dialog, a menu or a listbox popup is open on the page. */
export function isKeyboardLayerOpen(root: ParentNode = document): boolean {
for (const el of Array.from(root.querySelectorAll(KEYBOARD_LAYER_SELECTOR))) {
// Kept mounted but hidden (a closed drawer, an inert pane) doesn't count.
if (!el.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
}
return false;
}
/**
* Whether a page-wide app shortcut (Mail's j/k/Delete, the calendar's
* letters, Files' arrows) must leave this key press alone: someone already
* handled it, the user is typing, or a dialog, menu or popup is open and the
* key belongs to that. Without the last check Delete in the shortcuts help
* deleted the message behind it.
*/
export function isAppShortcutBlocked(event: KeyboardEvent): boolean {
return event.defaultPrevented || isEditableEventTarget(event) || isKeyboardLayerOpen();
}
/**
* A shortcut that is one printable character with no Ctrl, Alt or ⌘
* (j, #, ?, /). WCAG 2.1.4 asks that these can be turned off, since speech
* input types them by accident; Delete, the arrows, Enter and Escape are not
* character keys and stay.
*/
export function isSingleCharacterKey(event: Pick): boolean {
if (event.ctrlKey || event.metaKey || event.altKey) return false;
return [...event.key].length === 1;
}
/** Whether the key press started inside an element that matches `selector`. */
export function isEventInside(event: Event, selector: string): boolean {
const path =
typeof event.composedPath === "function"
? event.composedPath()
: [event.target];
return path.some((node) => node instanceof Element && node.matches(selector));
}
/**
* Controls that use the arrow keys themselves: a page-wide arrow shortcut
* must not take them from a slider, a resize handle, tabs, a grid or a menu.
*/
export function isArrowKeyWidgetTarget(event: Event): boolean {
return isEventInside(
event,
'select, [role="slider"], [role="separator"], [role="scrollbar"], [role="spinbutton"], [role="tablist"], [role="radiogroup"], [role="grid"], [role="treegrid"], [role="tree"], [role="listbox"], [role="menu"], [role="menubar"], [role="toolbar"]',
);
}
/**
* Focuses an element that may not take the focus by itself (a landmark, a
* pane): it gets tabindex="-1" until the focus leaves it again. Kept only
* while focused, since a container with tabindex keeps the focus on a click
* inside it, and the arrow keys then no longer scroll the list in it.
*/
export function focusTemporarily(el: HTMLElement, options?: FocusOptions): void {
const focusable = el.matches("a[href], button, input, select, textarea, [tabindex]");
if (!focusable) {
el.setAttribute("tabindex", "-1");
el.addEventListener("blur", () => el.removeAttribute("tabindex"), { once: true });
}
el.focus(options);
}