mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
fix(mobile): close the on-screen keyboard when tapping outside the terminal
On a phone the terminal holds focus on a hidden textarea, and nothing ever released it. Once the keyboard was up, tapping the header, the tab strip or any empty page chrome left it up — covering roughly half the screen with no in-app way to dismiss it. Repro, iPhone-class viewport (390x844), claude-mode session, focus the terminal then tap the header logo: | | document.activeElement after the tap | | --- | --- | | master | textarea.xterm-helper-textarea (keyboard stays up) | | this branch | body (keyboard closes) | A document-level touchend handler blurs the terminal input, deliberately scoped so focus is never stolen from something that wants it: - only when the terminal input actually holds focus; - never inside #terminalContainer — _handleMobileTerminalTap already classifies and routes those taps and owns that decision; - never on a control. Anything focusable or clickable is about to take focus itself, and the keyboard accessory bar exists to be used WHILE the keyboard is open, so dismissing there would fight the user. Bound to touchend rather than click: a tap meant to dismiss usually is not meant to activate what sits underneath, and touchend fires before the synthesized click so the blur lands first. The listener is passive — it never calls preventDefault. Test: `dismisses the on-screen keyboard when a tap lands outside the terminal` in test/mobile/keyboard.test.ts. It fails on master with a BEHAVIOURAL assertion (`expected 'xterm-helper-textarea' not to contain 'xterm-helper-textarea'`), not a TypeError, and passes here. It drives real dispatched touch events rather than calling the helper, because the handler is bound on document and a direct call would bypass the routing under test. test/mobile/keyboard.test.ts: 52 tests, 5 failed | 47 passed. Master is 51 tests, 5 failed | 46 passed — the same five pre-existing failures (stale layout and accessory-bar expectations, a CJK timeout), untouched here. Full suite: 4944 passed | 12 skipped, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -624,6 +624,69 @@ describe('Virtual Keyboard', () => {
|
||||
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('dismisses the on-screen keyboard when a tap lands outside the terminal', async () => {
|
||||
// The terminal holds focus on a hidden textarea and nothing released it,
|
||||
// so once the keyboard was up every tap on the header or page chrome left
|
||||
// it up — covering half a phone screen with no in-app way to close it.
|
||||
//
|
||||
// Driven as a real dispatched gesture: the handler is bound to touchend on
|
||||
// document, and calling the internal helper would bypass the routing this
|
||||
// test exists to check.
|
||||
const result = await page.evaluate(async () => {
|
||||
const tap = async (el: Element) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = Math.max(2, rect.left + Math.min(6, rect.width / 2));
|
||||
const y = Math.max(2, rect.top + Math.min(6, rect.height / 2));
|
||||
const target = document.elementFromPoint(x, y) || el;
|
||||
const touch = new Touch({ identifier: 21, target, clientX: x, clientY: y });
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchstart', {
|
||||
touches: [touch],
|
||||
targetTouches: [touch],
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchend', { touches: [], changedTouches: [touch], bubbles: true, cancelable: true })
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return document.activeElement?.className ?? '';
|
||||
};
|
||||
|
||||
app.hideWelcome();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) => app.terminal.write('transcript\r\n\r\n> ', resolve));
|
||||
|
||||
// Inert page chrome: the keyboard must close.
|
||||
app._focusMobileTerminalInput();
|
||||
const focusedBefore = document.activeElement?.className ?? '';
|
||||
const afterOutside = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body);
|
||||
|
||||
// A real control: it takes focus itself, so we must NOT interfere.
|
||||
app._focusMobileTerminalInput();
|
||||
const button = Array.from(document.querySelectorAll('button:not([disabled])')).find((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.width > 8 && rect.height > 8;
|
||||
});
|
||||
const afterButton = button ? await tap(button) : 'no-visible-button';
|
||||
|
||||
// Inside the terminal, tap classification owns the decision.
|
||||
app._focusMobileTerminalInput();
|
||||
const afterTerminal = await tap(document.querySelector('#terminalContainer')!);
|
||||
|
||||
return { focusedBefore, afterOutside, afterButton, afterTerminal };
|
||||
});
|
||||
|
||||
expect(result.focusedBefore).toContain('xterm-helper-textarea');
|
||||
// Red on master: the textarea keeps focus and the keyboard stays up.
|
||||
expect(result.afterOutside).not.toContain('xterm-helper-textarea');
|
||||
expect(result.afterButton).toContain('xterm-helper-textarea');
|
||||
expect(result.afterTerminal).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
it('routes CJK textarea typing through local echo on Enter', async () => {
|
||||
await page.evaluate(() => {
|
||||
window.__sentInputs = [];
|
||||
|
||||
Reference in New Issue
Block a user