mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
fix(mobile): route the bottom drawer's keyboard through the platform seam (OTA phase C follow-up) (#22556)
* fix(mobile): route the bottom drawer's keyboard through the platform seam Fill-mode sheets called Keyboard.metrics() directly, which react-native-web does not implement, so opening one on the page threw and the shell re-downloaded the workspace. The drawer now reads useSoftKeyboard, whose native half seeds from metrics() and carries the event duration, and whose web half answers from the window (duration 0). The fill/content-sized seed rule and resolveBottomDrawerKeyboardInset are unchanged. A census keeps Keyboard.metrics/addListener inside the seam plus the tab-sheet hide wait. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): retire the drawer's exemption from the page keyboard census The bottom drawer now reads the keyboard seam, so no module in the source-control or review closures names react-native-web's Keyboard stub. The census also flags Keyboard.metrics, which the stub lacks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the drawer an imperative keyboard pair from the seam The seam now exports subscribeSoftKeyboard and currentSoftKeyboardHeight beside its hooks. The drawer's effect is back to its original shape with only its Keyboard calls swapped for the pair, and useSoftKeyboard is back to {height, visible} with no metrics() seed. Seeding every consumer opened an iOS window between willHide and didHide where metrics() still reads open. The web pair answers from visualViewport, so it stays silent inside the shell and lifts sheets in a plain mobile browser. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start the web keyboard subscription from the current strip A keyboard already covering the page when subscribeSoftKeyboard attached never produced onHide when it closed, so the occlusion hook and a seeded fill sheet stayed lifted. Outside the shell only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -25,17 +25,6 @@ const HUB = 'app/h/[hostId]/source-control/[worktreeId].tsx'
|
||||
const REVIEW = 'app/h/[hostId]/review/[worktreeId].tsx'
|
||||
const SEAM = 'src/platform/keyboard-occlusion.web.ts'
|
||||
|
||||
/**
|
||||
* The one module that subscribes to the stub and is not the seam, exempt by name.
|
||||
*
|
||||
* `mounted-bottom-drawer.tsx` reads more than a height: `Keyboard.metrics()` for a sheet opened
|
||||
* over an already-raised keyboard, and each event's `duration` to animate with it. The seam models
|
||||
* neither, and the drawer is in C1's, C2's, C3's and C5's closures as well as these two, so moving
|
||||
* it is a change to every page rather than to this domain. Its listeners are inert on the web the
|
||||
* same way, which is exactly why the composer inside it takes its own padding here.
|
||||
*/
|
||||
const SUBSCRIBES_TO_THE_STUB = ['src/components/mounted-bottom-drawer.tsx']
|
||||
|
||||
/**
|
||||
* The seam itself, which is the one place allowed to name the stub.
|
||||
*
|
||||
@@ -56,7 +45,9 @@ function keyboardSubscribers(closure, rootDir = mobileDir) {
|
||||
.filter((file) => !SEAM_FILES.includes(file))
|
||||
.filter((file) => {
|
||||
try {
|
||||
return readFileSync(join(rootDir, file), 'utf8').includes('Keyboard.addListener')
|
||||
const source = readFileSync(join(rootDir, file), 'utf8')
|
||||
// `metrics()` too: the stub has none, so a page module calling it throws on the spot.
|
||||
return source.includes('Keyboard.addListener') || source.includes('Keyboard.metrics')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -69,7 +60,7 @@ describeClosure(
|
||||
() => {
|
||||
it.each([HUB, REVIEW])('measures it through the seam and nowhere else: %s', async (route) => {
|
||||
const closure = await mobileWebAppRouteClosure(route)
|
||||
expect(keyboardSubscribers(closure)).toEqual(SUBSCRIBES_TO_THE_STUB)
|
||||
expect(keyboardSubscribers(closure)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([HUB, REVIEW])('carries the seam, so the rule is not vacuous: %s', async (route) => {
|
||||
@@ -102,17 +93,6 @@ describeClosure(
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('names an exemption that is really in both closures, so it cannot outlive its subject', async () => {
|
||||
const [hub, review] = await Promise.all([
|
||||
mobileWebAppRouteClosure(HUB),
|
||||
mobileWebAppRouteClosure(REVIEW)
|
||||
])
|
||||
for (const file of SUBSCRIBES_TO_THE_STUB) {
|
||||
expect(hub.local, file).toContain(file)
|
||||
expect(review.local, file).toContain(file)
|
||||
}
|
||||
})
|
||||
},
|
||||
240_000
|
||||
)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
type Harness = {
|
||||
/** What `currentSoftKeyboardHeight()` answers: a keyboard already up when the sheet opens. */
|
||||
current: number
|
||||
show: ((height: number, duration: number) => void) | null
|
||||
hide: ((duration: number) => void) | null
|
||||
timings: { to: number; duration: number | undefined }[]
|
||||
/** Every shared-value write: a seed lands here directly, without a timing. */
|
||||
writes: number[]
|
||||
}
|
||||
|
||||
const harness = vi.hoisted((): Harness => ({
|
||||
current: 0,
|
||||
show: null,
|
||||
hide: null,
|
||||
timings: [],
|
||||
writes: []
|
||||
}))
|
||||
|
||||
vi.mock('../platform/keyboard-occlusion', () => ({
|
||||
currentSoftKeyboardHeight: () => harness.current,
|
||||
subscribeSoftKeyboard: (
|
||||
show: (height: number, duration: number) => void,
|
||||
hide: (duration: number) => void
|
||||
) => {
|
||||
harness.show = show
|
||||
harness.hide = hide
|
||||
return () => {
|
||||
harness.show = null
|
||||
harness.hide = null
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
|
||||
vi.mock('react-native', () => ({
|
||||
Keyboard: { dismiss: () => {} },
|
||||
Modal: 'Modal',
|
||||
Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
|
||||
View: 'View',
|
||||
useWindowDimensions: () => ({ width: 412, height: 900 })
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('react-native-gesture-handler', () => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of [
|
||||
'activeOffsetY',
|
||||
'simultaneousWithExternalGesture',
|
||||
'onBegin',
|
||||
'onUpdate',
|
||||
'onEnd'
|
||||
]) {
|
||||
chain[method] = () => chain
|
||||
}
|
||||
return {
|
||||
Gesture: { Pan: () => chain, Native: () => chain },
|
||||
GestureDetector: 'GestureDetector',
|
||||
GestureHandlerRootView: 'GestureHandlerRootView'
|
||||
}
|
||||
})
|
||||
vi.mock('react-native-reanimated', () => ({
|
||||
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
|
||||
useSharedValue: (initial: number) => {
|
||||
let value = initial
|
||||
return {
|
||||
get value() {
|
||||
return value
|
||||
},
|
||||
set value(next: number) {
|
||||
value = next
|
||||
harness.writes.push(next)
|
||||
}
|
||||
}
|
||||
},
|
||||
useAnimatedStyle: () => ({}),
|
||||
useAnimatedScrollHandler: () => () => {},
|
||||
withSpring: (to: number) => to,
|
||||
withTiming: (to: number, config?: { duration?: number }) => {
|
||||
harness.timings.push({ to, duration: config?.duration })
|
||||
return to
|
||||
},
|
||||
runOnJS: (fn: () => void) => fn,
|
||||
interpolate: () => 0,
|
||||
Extrapolation: { CLAMP: 'clamp' }
|
||||
}))
|
||||
|
||||
import { MountedBottomDrawer } from './mounted-bottom-drawer'
|
||||
|
||||
function sheet(fillAvailable: boolean) {
|
||||
return (
|
||||
<MountedBottomDrawer
|
||||
visible
|
||||
fillAvailable={fillAvailable}
|
||||
onClose={() => {}}
|
||||
onHidden={() => {}}
|
||||
>
|
||||
{null}
|
||||
</MountedBottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function render(fillAvailable: boolean): ReactTestRenderer {
|
||||
let renderer!: ReactTestRenderer
|
||||
act(() => {
|
||||
renderer = create(sheet(fillAvailable))
|
||||
})
|
||||
return renderer
|
||||
}
|
||||
|
||||
function keyboardShows(height: number, duration: number): void {
|
||||
act(() => harness.show?.(height, duration))
|
||||
}
|
||||
|
||||
function keyboardHides(duration: number): void {
|
||||
act(() => harness.hide?.(duration))
|
||||
}
|
||||
|
||||
function marginBottom(renderer: ReactTestRenderer): unknown {
|
||||
const node = renderer.root.find((candidate) => candidate.props.testID === 'bottom-drawer-sheet')
|
||||
const style: unknown[] = [node.props.style].flat(Infinity)
|
||||
return Object.assign({}, ...style.filter((entry) => typeof entry === 'object' && entry !== null))
|
||||
.marginBottom
|
||||
}
|
||||
|
||||
describe('the drawer riding the keyboard seam', () => {
|
||||
afterEach(() => {
|
||||
harness.current = 0
|
||||
harness.timings.length = 0
|
||||
harness.writes.length = 0
|
||||
})
|
||||
|
||||
it('docks a fill sheet on a keyboard already up when it opens', () => {
|
||||
harness.current = 300
|
||||
const renderer = render(true)
|
||||
expect(marginBottom(renderer)).toBe(300)
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('does not seed a content-sized sheet from a keyboard already up', () => {
|
||||
harness.current = 300
|
||||
const renderer = render(false)
|
||||
expect(harness.writes).not.toContain(300)
|
||||
// It still rides the keyboard's next event.
|
||||
keyboardShows(310, 0)
|
||||
expect(harness.timings).toContainEqual({ to: 310, duration: 250 })
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('lifts with the event duration and drops back when the keyboard hides', () => {
|
||||
const renderer = render(true)
|
||||
keyboardShows(280, 120)
|
||||
expect(marginBottom(renderer)).toBe(280)
|
||||
expect(harness.timings).toContainEqual({ to: 280, duration: 120 })
|
||||
keyboardHides(0)
|
||||
expect(marginBottom(renderer)).toBe(0)
|
||||
// An event without a duration still animates.
|
||||
expect(harness.timings).toContainEqual({ to: 0, duration: 250 })
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('unsubscribes on unmount', () => {
|
||||
const renderer = render(true)
|
||||
expect(harness.show).not.toBeNull()
|
||||
act(() => renderer.unmount())
|
||||
expect(harness.show).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The page's module graph: react-native-web's own `Keyboard` (no `metrics`, an inert
|
||||
// `addListener`) and the web sibling of the keyboard seam, as the page bundler resolves them.
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const { default: Keyboard }: { default: unknown } =
|
||||
// @ts-expect-error TS7016: react-native-web ships no type declarations.
|
||||
await import('react-native-web/dist/exports/Keyboard')
|
||||
return {
|
||||
BackHandler: { addEventListener: () => ({ remove: () => {} }) },
|
||||
Keyboard,
|
||||
Modal: 'Modal',
|
||||
Platform: { OS: 'web', select: (options: { web?: unknown }) => options.web },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
|
||||
View: 'View',
|
||||
// Subscribed, as react-native-web's is: the shell shortening the WebView is a resize.
|
||||
useWindowDimensions: () => {
|
||||
const read = () => ({ width: window.innerWidth, height: window.innerHeight })
|
||||
const [size, setSize] = React.useState(read)
|
||||
React.useEffect(() => {
|
||||
const onResize = () => setSize(read())
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
return size
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock(
|
||||
'../platform/keyboard-occlusion',
|
||||
async () => await import('../platform/keyboard-occlusion.web')
|
||||
)
|
||||
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('react-native-gesture-handler', () => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of [
|
||||
'activeOffsetY',
|
||||
'simultaneousWithExternalGesture',
|
||||
'onBegin',
|
||||
'onUpdate',
|
||||
'onEnd'
|
||||
]) {
|
||||
chain[method] = () => chain
|
||||
}
|
||||
return {
|
||||
Gesture: { Pan: () => chain, Native: () => chain },
|
||||
GestureDetector: 'GestureDetector',
|
||||
GestureHandlerRootView: 'GestureHandlerRootView'
|
||||
}
|
||||
})
|
||||
vi.mock('react-native-reanimated', () => ({
|
||||
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
|
||||
useSharedValue: (initial: number) => ({ value: initial }),
|
||||
useAnimatedStyle: () => ({}),
|
||||
useAnimatedScrollHandler: () => () => {},
|
||||
withSpring: (to: number) => to,
|
||||
withTiming: (to: number) => to,
|
||||
runOnJS: (fn: () => void) => fn,
|
||||
interpolate: () => 0,
|
||||
Extrapolation: { CLAMP: 'clamp' }
|
||||
}))
|
||||
|
||||
import { MountedBottomDrawer } from './mounted-bottom-drawer'
|
||||
|
||||
const RESTING_HEIGHT = 900
|
||||
const IME_HEIGHT = 300
|
||||
|
||||
function setWindowHeight(height: number): void {
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}
|
||||
|
||||
function renderFillSheet(): ReactTestRenderer {
|
||||
let renderer!: ReactTestRenderer
|
||||
act(() => {
|
||||
renderer = create(
|
||||
<MountedBottomDrawer visible fillAvailable onClose={() => {}} onHidden={() => {}}>
|
||||
{null}
|
||||
</MountedBottomDrawer>
|
||||
)
|
||||
})
|
||||
return renderer
|
||||
}
|
||||
|
||||
function sheetStyle(renderer: ReactTestRenderer): Record<string, unknown> {
|
||||
const sheet = renderer.root.find((node) => node.props.testID === 'bottom-drawer-sheet')
|
||||
const style: unknown[] = [sheet.props.style].flat(Infinity)
|
||||
return Object.assign({}, ...style.filter((entry) => typeof entry === 'object' && entry !== null))
|
||||
}
|
||||
|
||||
describe('a fill-mode sheet on the page', () => {
|
||||
afterEach(() => setWindowHeight(RESTING_HEIGHT))
|
||||
|
||||
it('mounts against react-native-web, whose Keyboard has no metrics()', () => {
|
||||
setWindowHeight(RESTING_HEIGHT)
|
||||
const renderer = renderFillSheet()
|
||||
expect(sheetStyle(renderer).marginBottom).toBe(0)
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('does not lift over a keyboard the shell already shortened the WebView for', () => {
|
||||
setWindowHeight(RESTING_HEIGHT)
|
||||
const renderer = renderFillSheet()
|
||||
act(() => setWindowHeight(RESTING_HEIGHT - IME_HEIGHT))
|
||||
// The shortened window is the whole avoidance: the sheet's height follows it and nothing lifts.
|
||||
expect(sheetStyle(renderer)).toMatchObject({
|
||||
marginBottom: 0,
|
||||
height: RESTING_HEIGHT - IME_HEIGHT - 24 - 16
|
||||
})
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
|
||||
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { useBackClaim } from '../navigation/use-back-claim'
|
||||
import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../platform/keyboard-occlusion'
|
||||
|
||||
const DISMISS_THRESHOLD = 80
|
||||
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
|
||||
@@ -129,8 +130,9 @@ export function MountedBottomDrawer({
|
||||
|
||||
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
|
||||
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
|
||||
// useAnimatedKeyboard). Keyboard event listeners work on both platforms
|
||||
// and give us the exact height to shift the drawer by.
|
||||
// useAnimatedKeyboard). The keyboard seam's events work on both platforms
|
||||
// and give the exact height; inside the shell's page none fire, because
|
||||
// the shell shortens the WebView above the IME.
|
||||
useEffect(() => {
|
||||
// Pinned-under sheets stay visible for size but must not ride the keyboard —
|
||||
// only the top interactive sheet owns inset/lift.
|
||||
@@ -159,26 +161,24 @@ export function MountedBottomDrawer({
|
||||
// keyboard before listeners attach. Seed only in fill mode so content-sized
|
||||
// outer sheets do not inherit a stale metrics height after an inner dismiss.
|
||||
if (fillAvailable) {
|
||||
const existing = Keyboard.metrics()
|
||||
if (existing != null && existing.height > 0) {
|
||||
applyKeyboardHeight(existing.height)
|
||||
const existing = currentSoftKeyboardHeight()
|
||||
if (existing > 0) {
|
||||
applyKeyboardHeight(existing)
|
||||
}
|
||||
}
|
||||
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
|
||||
|
||||
const onShow = Keyboard.addListener(showEvent, (e) => {
|
||||
applyKeyboardHeight(e.endCoordinates.height, e.duration || 250)
|
||||
})
|
||||
const onHide = Keyboard.addListener(hideEvent, (e) => {
|
||||
setKeyboardInset(0)
|
||||
keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
|
||||
})
|
||||
const unsubscribe = subscribeSoftKeyboard(
|
||||
(height, duration) => {
|
||||
applyKeyboardHeight(height, duration || 250)
|
||||
},
|
||||
(duration) => {
|
||||
setKeyboardInset(0)
|
||||
keyboardOffset.value = withTiming(0, { duration: duration || 250 })
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
onShow.remove()
|
||||
onHide.remove()
|
||||
unsubscribe()
|
||||
keyboardOffset.value = 0
|
||||
setKeyboardInset(0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Who may ask React Native's `Keyboard` for geometry: the keyboard seam, and one named exception.
|
||||
*
|
||||
* react-native-web's `Keyboard` has no `metrics` and an `addListener` that never fires, so a page
|
||||
* screen that asks it directly either throws (a fill sheet crashed the page on `metrics()`) or waits
|
||||
* forever. The seam has a web sibling that answers from the window instead; everything else reads it.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
|
||||
const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const SRC_DIR = fileURLToPath(new URL('../', import.meta.url))
|
||||
|
||||
const SEAM = 'src/platform/keyboard-occlusion.ts'
|
||||
/** A one-shot "open after the keyboard hides" with a timer fallback; it subscribes only while the
|
||||
* seam reports a keyboard, which on the page is never, so there it cannot wait forever. */
|
||||
const TAB_SHEET_AFTER_HIDE = 'src/session/use-mobile-session-terminal-send-actions.ts'
|
||||
|
||||
const asksKeyboard = (line: string): boolean =>
|
||||
line.includes('Keyboard.metrics(') || line.includes('Keyboard.addListener(')
|
||||
|
||||
function callers(): string[] {
|
||||
return censusSourceFiles(SRC_DIR)
|
||||
.filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file))
|
||||
.filter((file) => readFileSync(file, 'utf8').split('\n').some(asksKeyboard))
|
||||
.map((file) => relative(MOBILE_DIR, file))
|
||||
.sort()
|
||||
}
|
||||
|
||||
describe("React Native's Keyboard geometry", () => {
|
||||
it('is read only by the keyboard seam and the tab sheet that waits for a hide', () => {
|
||||
// Also the presence precondition: the seam itself must match, or the matcher is broken.
|
||||
expect(callers()).toEqual([SEAM, TAB_SHEET_AFTER_HIDE].sort())
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Listener = (event: { endCoordinates: { height: number } }) => void
|
||||
type Listener = (event: { endCoordinates: { height: number }; duration: number }) => void
|
||||
|
||||
type KeyboardHarness = {
|
||||
listeners: Map<string, Listener>
|
||||
@@ -11,17 +11,21 @@ type KeyboardHarness = {
|
||||
/** Every call, not the surviving subscriptions: a hook that subscribes and unsubscribes still
|
||||
* costs a phone a render per keyboard event, which `listeners.size` alone would not show. */
|
||||
addListenerCalls: number
|
||||
/** What `Keyboard.metrics()` answers. */
|
||||
metrics: { height: number } | undefined
|
||||
}
|
||||
|
||||
const keyboard = vi.hoisted((): KeyboardHarness => ({
|
||||
listeners: new Map(),
|
||||
removed: [],
|
||||
platform: 'ios',
|
||||
addListenerCalls: 0
|
||||
addListenerCalls: 0,
|
||||
metrics: undefined
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Keyboard: {
|
||||
metrics: () => keyboard.metrics,
|
||||
addListener: (name: string, listener: Listener) => {
|
||||
keyboard.addListenerCalls += 1
|
||||
keyboard.listeners.set(name, listener)
|
||||
@@ -41,6 +45,8 @@ vi.mock('react-native', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
currentSoftKeyboardHeight,
|
||||
subscribeSoftKeyboard,
|
||||
useKeyboardAvoidingPadding,
|
||||
useKeyboardOcclusion,
|
||||
useSoftKeyboard,
|
||||
@@ -86,6 +92,7 @@ describe('the keyboard the phone reports', () => {
|
||||
keyboard.removed.length = 0
|
||||
keyboard.platform = 'ios'
|
||||
keyboard.addListenerCalls = 0
|
||||
keyboard.metrics = undefined
|
||||
lift = 0
|
||||
padding = 0
|
||||
keyboardState = { height: 0, visible: false }
|
||||
@@ -104,11 +111,14 @@ describe('the keyboard the phone reports', () => {
|
||||
it('lifts by the height the event carries and drops back on hide', async () => {
|
||||
await mount()
|
||||
await act(async () => {
|
||||
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } })
|
||||
keyboard.listeners.get('keyboardWillShow')?.({
|
||||
endCoordinates: { height: 336 },
|
||||
duration: 250
|
||||
})
|
||||
})
|
||||
expect(lift).toBe(336)
|
||||
await act(async () => {
|
||||
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 } })
|
||||
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 0 })
|
||||
})
|
||||
expect(lift).toBe(0)
|
||||
})
|
||||
@@ -116,7 +126,7 @@ describe('the keyboard the phone reports', () => {
|
||||
it('never reports a negative height, whatever the event says', async () => {
|
||||
await mount()
|
||||
await act(async () => {
|
||||
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: -10 } })
|
||||
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: -10 }, duration: 0 })
|
||||
})
|
||||
expect(lift).toBe(0)
|
||||
})
|
||||
@@ -144,11 +154,14 @@ describe('the keyboard the phone reports', () => {
|
||||
await mountComponent(StateHarness)
|
||||
expect(keyboard.addListenerCalls).toBe(2)
|
||||
await act(async () => {
|
||||
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } })
|
||||
keyboard.listeners.get('keyboardWillShow')?.({
|
||||
endCoordinates: { height: 336 },
|
||||
duration: 250
|
||||
})
|
||||
})
|
||||
expect(keyboardState).toEqual({ height: 336, visible: true })
|
||||
await act(async () => {
|
||||
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 } })
|
||||
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 0 })
|
||||
})
|
||||
expect(keyboardState).toEqual({ height: 0, visible: false })
|
||||
})
|
||||
@@ -156,8 +169,34 @@ describe('the keyboard the phone reports', () => {
|
||||
it('calls a keyboard that reports no height open anyway, because the event is the fact', async () => {
|
||||
await mountComponent(StateHarness)
|
||||
await act(async () => {
|
||||
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 } })
|
||||
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 }, duration: 0 })
|
||||
})
|
||||
expect(keyboardState).toEqual({ height: 0, visible: true })
|
||||
})
|
||||
|
||||
it('hands a sheet each event with its duration, and removes both listeners', () => {
|
||||
const calls: string[] = []
|
||||
const unsubscribe = subscribeSoftKeyboard(
|
||||
(height, duration) => calls.push(`show ${height} ${duration}`),
|
||||
(duration) => calls.push(`hide ${duration}`)
|
||||
)
|
||||
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 }, duration: 250 })
|
||||
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 180 })
|
||||
expect(calls).toEqual(['show 336 250', 'hide 180'])
|
||||
unsubscribe()
|
||||
expect(keyboard.removed.sort()).toEqual(['keyboardWillHide', 'keyboardWillShow'])
|
||||
})
|
||||
|
||||
it('reads a keyboard already up from metrics(), and 0 when there is none', () => {
|
||||
expect(currentSoftKeyboardHeight()).toBe(0)
|
||||
keyboard.metrics = { height: 291 }
|
||||
expect(currentSoftKeyboardHeight()).toBe(291)
|
||||
})
|
||||
|
||||
it('never seeds the hook from metrics(), so a stale iOS reading cannot open it', async () => {
|
||||
// RN clears metrics() on didHide, after the willHide this hook closes on.
|
||||
keyboard.metrics = { height: 291 }
|
||||
await mountComponent(StateHarness)
|
||||
expect(keyboardState).toEqual({ height: 0, visible: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,27 +21,49 @@ const CLOSED: SoftKeyboardState = { height: 0, visible: false }
|
||||
export function useSoftKeyboard(): SoftKeyboardState {
|
||||
const [keyboard, setKeyboard] = useState<SoftKeyboardState>(CLOSED)
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
|
||||
|
||||
const onShow = Keyboard.addListener(showEvent, (event) => {
|
||||
useEffect(
|
||||
() =>
|
||||
// The keyboard's own height already describes the obscured area; the consumer adds whatever
|
||||
// clearance it wants above it. Open is the event, not the height: a keyboard that reports 0
|
||||
// is still one nobody wants the terminal re-fitted under.
|
||||
setKeyboard({ height: Math.max(0, event.endCoordinates.height), visible: true })
|
||||
})
|
||||
const onHide = Keyboard.addListener(hideEvent, () => setKeyboard(CLOSED))
|
||||
|
||||
return () => {
|
||||
onShow.remove()
|
||||
onHide.remove()
|
||||
}
|
||||
}, [])
|
||||
subscribeSoftKeyboard(
|
||||
(height) => setKeyboard({ height: Math.max(0, height), visible: true }),
|
||||
() => setKeyboard(CLOSED)
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
return keyboard
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyboard as events, for a consumer that animates with each one (the event's `duration`) rather
|
||||
* than rendering from state. iOS is told `will`, Android `did`.
|
||||
*/
|
||||
export function subscribeSoftKeyboard(
|
||||
onShow: (height: number, duration: number) => void,
|
||||
onHide: (duration: number) => void
|
||||
): () => void {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
|
||||
const show = Keyboard.addListener(showEvent, (event) =>
|
||||
onShow(event.endCoordinates.height, event.duration)
|
||||
)
|
||||
const hide = Keyboard.addListener(hideEvent, (event) => onHide(event.duration))
|
||||
return () => {
|
||||
show.remove()
|
||||
hide.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyboard already up, which sends no show event to a late subscriber. React Native sets this on
|
||||
* `did` events, so on iOS it can still read open between `willHide` and `didHide`.
|
||||
*/
|
||||
export function currentSoftKeyboardHeight(): number {
|
||||
return Keyboard.metrics()?.height ?? 0
|
||||
}
|
||||
|
||||
/** The occluded strip alone, for the callers that lift by it and never ask whether it is open. */
|
||||
export function useKeyboardOcclusion(): number {
|
||||
return useSoftKeyboard().height
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
currentSoftKeyboardHeight,
|
||||
subscribeSoftKeyboard,
|
||||
useKeyboardAvoidingPadding,
|
||||
useKeyboardOcclusion,
|
||||
useSoftKeyboard,
|
||||
@@ -201,6 +203,65 @@ describe('the keyboard the browser reports', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** The pair a sheet animates with: events rather than state, each with duration 0. */
|
||||
describe('the keyboard as events, for a sheet', () => {
|
||||
it('shows by the uncovered strip and hides once when it closes', () => {
|
||||
const calls: string[] = []
|
||||
const unsubscribe = subscribeSoftKeyboard(
|
||||
(height, duration) => calls.push(`show ${height} ${duration}`),
|
||||
(duration) => calls.push(`hide ${duration}`)
|
||||
)
|
||||
viewport?.resizeTo(464)
|
||||
viewport?.resizeTo(LAYOUT_HEIGHT)
|
||||
viewport?.resizeTo(LAYOUT_HEIGHT)
|
||||
expect(calls).toEqual(['show 336 0', 'hide 0'])
|
||||
unsubscribe()
|
||||
expect(viewport?.counts).toEqual({ resize: 0, scroll: 0 })
|
||||
})
|
||||
|
||||
it('hides a keyboard that was already up when it subscribed', async () => {
|
||||
viewport?.resizeTo(464)
|
||||
const calls: string[] = []
|
||||
const unsubscribe = subscribeSoftKeyboard(
|
||||
(height) => calls.push(`show ${height}`),
|
||||
(duration) => calls.push(`hide ${duration}`)
|
||||
)
|
||||
viewport?.resizeTo(LAYOUT_HEIGHT)
|
||||
expect(calls).toEqual(['hide 0'])
|
||||
unsubscribe()
|
||||
// The hook seeds from the same strip, so it must come back down too.
|
||||
viewport?.resizeTo(464)
|
||||
await mount()
|
||||
expect(lift).toBe(336)
|
||||
await act(async () => viewport?.resizeTo(LAYOUT_HEIGHT))
|
||||
expect(lift).toBe(0)
|
||||
})
|
||||
|
||||
it('stays silent when nothing is covered, which is the shell shortening the WebView', () => {
|
||||
const calls: string[] = []
|
||||
const unsubscribe = subscribeSoftKeyboard(
|
||||
() => calls.push('show'),
|
||||
() => calls.push('hide')
|
||||
)
|
||||
viewport?.resizeTo(LAYOUT_HEIGHT)
|
||||
expect(calls).toEqual([])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('reads a keyboard already up, and 0 without a visual viewport', () => {
|
||||
viewport?.resizeTo(464)
|
||||
expect(currentSoftKeyboardHeight()).toBe(336)
|
||||
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true })
|
||||
expect(currentSoftKeyboardHeight()).toBe(0)
|
||||
expect(() =>
|
||||
subscribeSoftKeyboard(
|
||||
() => {},
|
||||
() => {}
|
||||
)()
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
const LAYOUT_WIDTH = 400
|
||||
let keyboardState: SoftKeyboardState = { height: 0, visible: false }
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ import { useEffect, useState } from 'react'
|
||||
* `scale` is read defensively because older WebViews do not implement it, and treating its absence
|
||||
* as zoomed would answer 0 for every keyboard on them.
|
||||
*
|
||||
* No `visualViewport` at all is 0 rather than a guess — that guard is in the effect below, which
|
||||
* is also the only thing that can act on it, and a second copy here was unreachable.
|
||||
* No `visualViewport` at all is 0 rather than a guess — that guard is in the two readers below,
|
||||
* which are the only things that can act on it.
|
||||
*/
|
||||
function occlusion(viewport: VisualViewport): number {
|
||||
if ((viewport.scale ?? 1) !== 1) {
|
||||
@@ -42,25 +42,59 @@ function occlusion(viewport: VisualViewport): number {
|
||||
return Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop))
|
||||
}
|
||||
|
||||
/** Both shapes: `null` is what the DOM declares, `undefined` is a WebView without the property. */
|
||||
function visualViewport(): VisualViewport | undefined {
|
||||
return window.visualViewport ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip as events, with duration 0: the browser has already moved the viewport by the time it
|
||||
* says so. Inside the shell nothing fires, because the shell shortens the WebView above the IME and
|
||||
* the strip stays 0; in a plain mobile browser the keyboard overlays the page and this is the lift.
|
||||
*/
|
||||
export function subscribeSoftKeyboard(
|
||||
onShow: (height: number, duration: number) => void,
|
||||
onHide: (duration: number) => void
|
||||
): () => void {
|
||||
const viewport = visualViewport()
|
||||
if (viewport === undefined) {
|
||||
return () => {}
|
||||
}
|
||||
let open = occlusion(viewport) > 0
|
||||
const read = (): void => {
|
||||
const height = occlusion(viewport)
|
||||
if (height > 0) {
|
||||
open = true
|
||||
onShow(height, 0)
|
||||
} else if (open) {
|
||||
open = false
|
||||
onHide(0)
|
||||
}
|
||||
}
|
||||
viewport.addEventListener('resize', read)
|
||||
viewport.addEventListener('scroll', read)
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener('resize', read)
|
||||
viewport.removeEventListener('scroll', read)
|
||||
}
|
||||
}
|
||||
|
||||
/** A keyboard already up when a composer opens gets no event at all. */
|
||||
export function currentSoftKeyboardHeight(): number {
|
||||
const viewport = visualViewport()
|
||||
return viewport === undefined ? 0 : occlusion(viewport)
|
||||
}
|
||||
|
||||
export function useKeyboardOcclusion(): number {
|
||||
const [keyboardLift, setKeyboardLift] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// Both shapes: `null` is what the DOM declares, `undefined` is a WebView without the property.
|
||||
const viewport = window.visualViewport
|
||||
if (viewport === null || viewport === undefined) {
|
||||
return
|
||||
}
|
||||
const read = (): void => setKeyboardLift(occlusion(viewport))
|
||||
// Read once on mount: a composer opened while the keyboard is already up gets no event at all.
|
||||
read()
|
||||
viewport.addEventListener('resize', read)
|
||||
viewport.addEventListener('scroll', read)
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener('resize', read)
|
||||
viewport.removeEventListener('scroll', read)
|
||||
}
|
||||
setKeyboardLift(currentSoftKeyboardHeight())
|
||||
return subscribeSoftKeyboard(
|
||||
(height) => setKeyboardLift(height),
|
||||
() => setKeyboardLift(0)
|
||||
)
|
||||
}, [])
|
||||
|
||||
return keyboardLift
|
||||
|
||||
Reference in New Issue
Block a user