feat(mobile): add explicit keyboard dismiss control to terminal command dock (#5917)
* feat(mobile): add explicit keyboard dismiss control to terminal command dock Add a fixed Hide control at the left of the terminal command dock accessory bar whenever the software keyboard is open (keyboardHeight > 0). Tapping it clears any pending live-input focus timer, blurs the live and buffered command inputs, and dismisses the keyboard without sending bytes, switching input mode, or clearing typed text. The dismiss behavior lives in a dedicated, unit-tested terminal-keyboard-dismiss module rather than the customizable accessory-key path, so the escape hatch cannot be hidden by user shortcut customization. Available on every platform where the IME covers the app (iOS and Android). * review: harden keyboard dismiss control per adversarial review - document the load-bearing clear-before-blur order in dismissTerminalKeyboard - cover the both-handles-missing case in unit tests (5/5) - move the #5106 first-tap comment onto the accessory ScrollView and add a why-comment for the fixed Hide control - add accessibilityRole=button and hitSlop to the Hide control for a larger, semantically-correct touch target * fix(mobile): harden hide button visibility and scroll layout * refactor(mobile): use stacked keyboard+chevron glyph for dismiss control Replace the icon+'Hide' text with the iOS-native dismiss glyph (keyboard with a chevron-down beneath it). Narrower in the accessory row, removes the icon/word redundancy, and reads as distinct from the >> input-mode toggle. Accessibility label/hint/role unchanged. * fix(mobile): align keyboard dismiss accessory height * test(mobile): align vitest transform with Vite 8 --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
parent
582fe5948e
commit
60037d60ab
|
|
@ -24,6 +24,7 @@ import {
|
|||
AlertTriangle,
|
||||
ArrowUp,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsRight,
|
||||
|
|
@ -100,6 +101,7 @@ import {
|
|||
isTerminalLiveInputWithinByteLimit,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
|
|
@ -1006,6 +1008,7 @@ export default function SessionScreen() {
|
|||
const viewportMeasuredRef = useRef(false)
|
||||
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
|
||||
const liveInputRef = useRef<TextInput>(null)
|
||||
const commandInputRef = useRef<TextInput>(null)
|
||||
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
|
||||
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
|
||||
|
|
@ -3208,6 +3211,15 @@ export default function SessionScreen() {
|
|||
]
|
||||
)
|
||||
|
||||
const dismissSoftwareKeyboard = useCallback(() => {
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
|
||||
commandInput: commandInputRef.current,
|
||||
dismissKeyboard: () => Keyboard.dismiss(),
|
||||
liveInput: liveInputRef.current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleTerminalTap = useCallback(
|
||||
(handle: string) => {
|
||||
if (handle !== activeHandleRef.current) {
|
||||
|
|
@ -4780,10 +4792,38 @@ export default function SessionScreen() {
|
|||
>
|
||||
{/* Accessory keys */}
|
||||
<View style={styles.accessoryBar}>
|
||||
{/* Why: a fixed, always-visible escape hatch from the open
|
||||
keyboard. Kept outside the horizontal ScrollView so it does
|
||||
not scroll away, and out of the terminal-byte shortcut path so
|
||||
it cannot be hidden by user shortcut customization (#5106). */}
|
||||
{keyboardLift > 0 && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.keyboardDismissKey,
|
||||
pressed && styles.accessoryKeyPressed
|
||||
]}
|
||||
onPress={dismissSoftwareKeyboard}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss keyboard"
|
||||
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
|
||||
>
|
||||
<View style={styles.keyboardDismissGlyph}>
|
||||
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
|
||||
<ChevronDown
|
||||
size={10}
|
||||
color={colors.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
style={styles.keyboardDismissChevron}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
{/* Why: with default tap handling the first tap on any accessory
|
||||
key dismisses the open keyboard and is swallowed, so live
|
||||
input lost its keyboard on every Esc/Tab press (#5106). */}
|
||||
<ScrollView
|
||||
style={styles.accessoryScroll}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.accessoryContent}
|
||||
|
|
@ -5009,6 +5049,7 @@ export default function SessionScreen() {
|
|||
) : (
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
ref={commandInputRef}
|
||||
// Why: Android caches the IME inputType at mount, so toggling
|
||||
// autocomplete must remount there; iOS can update without a focus-costly remount.
|
||||
key={
|
||||
|
|
|
|||
|
|
@ -68,10 +68,16 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
|
|||
zIndex: 20
|
||||
},
|
||||
accessoryBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
accessoryScroll: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
accessoryContent: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
|
|
@ -110,6 +116,29 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
|
|||
accessoryKeyTextDisabled: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
keyboardDismissKey: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
marginVertical: spacing.xs,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: 0,
|
||||
borderRadius: radii.button,
|
||||
minWidth: 36,
|
||||
height: 28
|
||||
},
|
||||
keyboardDismissGlyph: {
|
||||
alignItems: 'center',
|
||||
height: 18,
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
width: 18
|
||||
},
|
||||
keyboardDismissChevron: {
|
||||
bottom: -2,
|
||||
position: 'absolute'
|
||||
},
|
||||
inputBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { dismissTerminalKeyboard } from './terminal-keyboard-dismiss'
|
||||
|
||||
describe('dismissTerminalKeyboard', () => {
|
||||
it('clears pending live input focus before blur and dismiss', () => {
|
||||
const calls: string[] = []
|
||||
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
|
||||
const liveInput = { blur: vi.fn(() => calls.push('live-blur')) }
|
||||
const commandInput = { blur: vi.fn(() => calls.push('command-blur')) }
|
||||
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus,
|
||||
commandInput,
|
||||
dismissKeyboard,
|
||||
liveInput
|
||||
})
|
||||
|
||||
expect(calls).toEqual(['clear', 'live-blur', 'command-blur', 'dismiss'])
|
||||
})
|
||||
|
||||
it('blurs both live and buffered command inputs', () => {
|
||||
const liveInput = { blur: vi.fn() }
|
||||
const commandInput = { blur: vi.fn() }
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: vi.fn(),
|
||||
commandInput,
|
||||
dismissKeyboard: vi.fn(),
|
||||
liveInput
|
||||
})
|
||||
|
||||
expect(liveInput.blur).toHaveBeenCalledTimes(1)
|
||||
expect(commandInput.blur).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses the keyboard without a live input handle', () => {
|
||||
const commandInput = { blur: vi.fn() }
|
||||
const dismissKeyboard = vi.fn()
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: vi.fn(),
|
||||
commandInput,
|
||||
dismissKeyboard,
|
||||
liveInput: null
|
||||
})
|
||||
|
||||
expect(commandInput.blur).toHaveBeenCalledTimes(1)
|
||||
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses the keyboard without a buffered command input handle', () => {
|
||||
const liveInput = { blur: vi.fn() }
|
||||
const dismissKeyboard = vi.fn()
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: vi.fn(),
|
||||
commandInput: undefined,
|
||||
dismissKeyboard,
|
||||
liveInput
|
||||
})
|
||||
|
||||
expect(liveInput.blur).toHaveBeenCalledTimes(1)
|
||||
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still clears focus and dismisses when both input handles are missing', () => {
|
||||
const calls: string[] = []
|
||||
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
|
||||
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus,
|
||||
commandInput: undefined,
|
||||
dismissKeyboard,
|
||||
liveInput: null
|
||||
})
|
||||
|
||||
expect(calls).toEqual(['clear', 'dismiss'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export type TerminalKeyboardDismissHandle = { blur: () => void } | null | undefined
|
||||
|
||||
export type DismissTerminalKeyboardOptions = {
|
||||
clearPendingLiveInputFocus: () => void
|
||||
commandInput: TerminalKeyboardDismissHandle
|
||||
dismissKeyboard: () => void
|
||||
liveInput: TerminalKeyboardDismissHandle
|
||||
}
|
||||
|
||||
export function dismissTerminalKeyboard(options: DismissTerminalKeyboardOptions): void {
|
||||
// Why: clear the queued live-input focus before blurring/dismissing so a
|
||||
// pending deferred focus cannot re-open the iOS keyboard right after Hide.
|
||||
options.clearPendingLiveInputFocus()
|
||||
options.liveInput?.blur()
|
||||
options.commandInput?.blur()
|
||||
options.dismissKeyboard()
|
||||
}
|
||||
|
|
@ -1,25 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const tsconfigRaw = JSON.stringify({
|
||||
compilerOptions: {
|
||||
jsx: 'react-jsx',
|
||||
module: 'esnext',
|
||||
moduleResolution: 'bundler',
|
||||
strict: true,
|
||||
target: 'es2022'
|
||||
}
|
||||
})
|
||||
const vitestOxcConfig = { tsconfig: false } as never
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
esbuild: {
|
||||
tsconfigRaw
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
tsconfigRaw
|
||||
}
|
||||
},
|
||||
// Why: the app tsconfig intentionally excludes tests; Vite 8's OXC transform
|
||||
// otherwise fails before Vitest can run the test modules.
|
||||
oxc: vitestOxcConfig,
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts']
|
||||
|
|
|
|||
Loading…
Reference in New Issue