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
3 changes: 3 additions & 0 deletions changelog.d/android-key-bar-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed
- On Android, a tap on a terminal key-bar key (arrows, Esc, Tab, Ctrl) no longer brings back a
keyboard that Back put away.
21 changes: 21 additions & 0 deletions scripts/keybar-focus-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ export async function checkKeyBarKeepsKeyboardDown(browser: Browser, origin: str
await page.locator(shownRows(), { hasText: marker }).waitFor({ timeout: 10_000 }).catch(async (error) => {
throw new Error(`${tag}: the key bar's Enter did not run the typed command (${marker}); screen: ${JSON.stringify((await page.locator(shownRows()).first().textContent())?.slice(-300))}`, { cause: error });
});
if (!touch) continue;
// Android's Back puts the keyboard away but leaves the field focused, and Chrome raises the
// keyboard again on any tap while a text field has the focus (#3274): at the key's click, the
// tap's end, the field must ask for no keyboard (inputmode=none), and get its own back after
const field = mode === "line" ? ".terminal-input-text" : ".xterm-helper-textarea";
await page.locator(field).focus();
await page.evaluate(() => {
(window as unknown as { atClick_: (string | null)[] }).atClick_ = [];
document.addEventListener("click", () => (window as unknown as { atClick_: (string | null)[] }).atClick_.push(document.activeElement?.getAttribute("inputmode") ?? null), { capture: true });
});
const modeBefore = await page.locator(field).getAttribute("inputmode");
const sentBefore = await page.evaluate(() => (window as unknown as InputWindow).typed_.length);
await page.locator('.key-bar [data-key="Escape"]').tap();
assert.deepEqual(await page.evaluate(() => (window as unknown as { atClick_: (string | null)[] }).atClick_), ["none"], `${tag}: at a key tap's click the focused field asks for no keyboard`);
assert.equal(await page.evaluate((field) => document.activeElement?.matches(field) === true, field), true, `${tag}: the field keeps the focus`);
await page.waitForFunction(([pane, from]) => (window as unknown as InputWindow).typed_.slice(from).some((frame) => frame.pane === pane && frame.text === "\x1b"), [pane, sentBefore] as const, { timeout: 10_000 });
await page.waitForFunction(([field, before]) => document.querySelector(field)?.getAttribute("inputmode") === before, [field, modeBefore] as const, { timeout: 10_000 });
// More keys only opens the second row: its tap holds the keyboard down as a key's does
await page.locator('.key-bar [data-key="more"]').tap();
assert.deepEqual(await page.evaluate(() => (window as unknown as { atClick_: (string | null)[] }).atClick_), ["none", "none"], `${tag}: at More keys' click the focused field asks for no keyboard`);
await page.waitForFunction(([field, before]) => document.querySelector(field)?.getAttribute("inputmode") === before, [field, modeBefore] as const, { timeout: 10_000 });
} finally { await context.close(); }
}
console.log("PASS key-bar taps on a touch screen send their keys without taking the focus (the keyboard stays down), in Direct typing and Input line modes; a fine pointer's click still focuses the grid");
Expand Down
51 changes: 46 additions & 5 deletions src/components/KeyBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,38 @@ function keepFocus(event: PointerEvent<HTMLButtonElement> | MouseEvent<HTMLButto
event.preventDefault();
}

/**
* Android's Back puts the soft keyboard away but leaves the field focused, and Chrome raises the
* keyboard again on any tap while a text field has the focus, a key's tap too (#3274). For the touch
* of a key that leaves the focus where it is (a key, a modifier, More keys), the focused field asks for no keyboard (inputmode=none), which also leaves a keyboard that
* is up where it is; it gets its own inputmode back once the tap's click is handled, at the next
* press anywhere, or when it loses the focus.
*/
let keyboardHold: (() => void) | null = null;
function releaseKeyboardHold(): void {
const release = keyboardHold;
keyboardHold = null;
release?.();
}
function holdKeyboardForTap(): void {
releaseKeyboardHold();
const field = document.activeElement;
if (!(field instanceof HTMLElement) || !(field.matches("textarea, input") || field.isContentEditable)) return;
const previous = field.getAttribute("inputmode");
if (previous === "none") return;
field.setAttribute("inputmode", "none");
keyboardHold = (): void => {
document.removeEventListener("pointerdown", releaseKeyboardHold, true);
field.removeEventListener("blur", releaseKeyboardHold);
if (field.getAttribute("inputmode") !== "none") return;
if (previous === null) field.removeAttribute("inputmode");
else field.setAttribute("inputmode", previous);
};
// added while this press is dispatched, past the document's capture phase: the next press ends it
document.addEventListener("pointerdown", releaseKeyboardHold, true);
field.addEventListener("blur", releaseKeyboardHold);
}

interface KeyProps {
disabled?: boolean;
/**
Expand All @@ -79,6 +111,8 @@ interface KeyProps {
* tooltip, where a key is neither. A press still keeps the focus where it is (keepFocus)
*/
command?: boolean;
/** a key that leaves the focus alone: its touch keeps a keyboard that is down, down (holdKeyboardForTap) */
holdsKeyboard?: boolean;
dataKey: string;
label?: string;
pressed?: boolean;
Expand All @@ -95,7 +129,7 @@ interface KeyProps {
children: ReactNode;
}

function Key({ dataKey, label, pressed, latch, expanded, onPress, onHoldEnd, children, disabled, refused, command, repeatable, holdScope }: KeyProps) {
function Key({ dataKey, label, pressed, latch, expanded, onPress, onHoldEnd, children, disabled, refused, command, holdsKeyboard, repeatable, holdScope }: KeyProps) {
const onPressRef = useRef(onPress);
onPressRef.current = onPress;
const onHoldEndRef = useRef(onHoldEnd);
Expand Down Expand Up @@ -152,6 +186,7 @@ function Key({ dataKey, label, pressed, latch, expanded, onPress, onHoldEnd, chi
tabIndex={command ? 0 : -1}
onPointerDown={(event) => {
keepFocus(event);
if (event.pointerType === "touch" && holdsKeyboard) holdKeyboardForTap();
if (!repeatable || disabled || event.button !== 0 || !event.isPrimary || pointerRef.current.id !== null) return;
const pointer = pointerRef.current;
pointer.id = event.pointerId;
Expand All @@ -171,11 +206,17 @@ function Key({ dataKey, label, pressed, latch, expanded, onPress, onHoldEnd, chi
// Ignore the lost capture that follows release, preserving the click gate.
pointerRef.current.id = null;
} : undefined}
onPointerCancel={repeatable ? cancelPointer : undefined}
onPointerCancel={(event) => {
// a touch that turned into the row's scroll brings no click
releaseKeyboardHold();
if (repeatable) cancelPointer(event);
}}
onLostPointerCapture={repeatable ? cancelPointer : undefined}
onMouseDown={keepFocus}
onContextMenu={keepFocus}
onClick={() => {
// Chrome raises the keyboard right after the tap's click: the field asks for none until then
if (keyboardHold !== null) window.setTimeout(releaseKeyboardHold, 0);
if (refused) return;
if (!repeatable) {
onPress(false);
Expand Down Expand Up @@ -344,7 +385,7 @@ export function KeyBar({ onKey, onKeyHoldEnd, modifiers, lockedModifiers, onTogg
const label = item.modifiers !== undefined
? t("Press {key}", { key: [item.modifiers.ctrl && "Ctrl", item.modifiers.alt && "Alt", item.modifiers.shift && "Shift", spoken].filter(Boolean).join("+") })
: spoken === keyBarItemLabel(item) ? undefined : spoken;
return <Key key={keyBarItemId(item)} disabled={disabled} dataKey={item.modifiers === undefined ? item.key : keyBarItemId(item)} label={label} onPress={(repeat) => onKey(item, repeat)}
return <Key key={keyBarItemId(item)} disabled={disabled} holdsKeyboard dataKey={item.modifiers === undefined ? item.key : keyBarItemId(item)} label={label} onPress={(repeat) => onKey(item, repeat)}
onHoldEnd={item.modifiers === undefined ? onKeyHoldEnd : undefined} repeatable={isRepeatableKeyBarItem(item)} holdScope={holdScope}>
{arrow && item.modifiers === undefined ? <Chevron direction={arrow.direction} /> : keyBarItemLabel(item)}
</Key>;
Expand All @@ -356,7 +397,7 @@ export function KeyBar({ onKey, onKeyHoldEnd, modifiers, lockedModifiers, onTogg
// Alt and Shift are said as written; Ctrl is not. A locked one says so: held past the next key
const name = modifier === "ctrl" ? t("Control") : cap;
const label = latch === "locked" ? t("{key}, locked", { key: name }) : modifier === "ctrl" ? name : undefined;
return <Key key={`modifier:${modifier}`} disabled={disabled} dataKey={dataKey} label={label} pressed={modifiers[modifier]} latch={latch} onPress={() => onToggleModifier(modifier)}>{cap}</Key>;
return <Key key={`modifier:${modifier}`} disabled={disabled} holdsKeyboard dataKey={dataKey} label={label} pressed={modifiers[modifier]} latch={latch} onPress={() => onToggleModifier(modifier)}>{cap}</Key>;
};
return (
<div className={`key-bar${expanded ? " is-expanded" : ""}`} ref={barRef} role="group" aria-label={t("Terminal keys")}>
Expand Down Expand Up @@ -409,7 +450,7 @@ export function KeyBar({ onKey, onKeyHoldEnd, modifiers, lockedModifiers, onTogg
}} />}
{/* pinned at the row's end, out of its scroll: the way to the second row is never scrolled away */}
{onToggleExpanded && (
<Key command dataKey="more" label={t(expanded ? "Fewer keys" : "More keys")} expanded={expanded} onPress={onToggleExpanded}>
<Key command holdsKeyboard dataKey="more" label={t(expanded ? "Fewer keys" : "More keys")} expanded={expanded} onPress={onToggleExpanded}>
{expanded ? <ChevronDown aria-hidden="true" /> : <ChevronUp aria-hidden="true" />}
</Key>
)}
Expand Down
Loading