diff --git a/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts new file mode 100644 index 0000000..da952bc --- /dev/null +++ b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts @@ -0,0 +1,197 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { subscribeKeyboardHeight } from '../../utils/keyboardHeight/subscribeKeyboardHeight.ts'; + +import { useKeyboardHeight } from './useKeyboardHeight.ts'; + +vi.mock('../../utils/keyboardHeight/subscribeKeyboardHeight.ts', () => ({ + subscribeKeyboardHeight: vi.fn(), +})); + +const mockSubscribeKeyboardHeight = vi.mocked(subscribeKeyboardHeight); + +describe('useKeyboardHeight', () => { + let mockUnsubscribe: ReturnType; + + beforeEach(() => { + mockUnsubscribe = vi.fn(); + mockSubscribeKeyboardHeight.mockReturnValue({ unsubscribe: mockUnsubscribe }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state', () => { + it('should return 0 as initial keyboard height', () => { + const { result } = renderHook(() => useKeyboardHeight()); + + expect(result.current.keyboardHeight).toBe(0); + }); + }); + + describe('subscription behavior', () => { + it('should call subscribeKeyboardHeight with immediate: true by default', () => { + renderHook(() => useKeyboardHeight()); + + expect(mockSubscribeKeyboardHeight).toHaveBeenCalledWith({ + callback: expect.any(Function), + immediate: true, + }); + }); + + it('should call subscribeKeyboardHeight with immediate: false when specified', () => { + renderHook(() => useKeyboardHeight({ immediate: false })); + + expect(mockSubscribeKeyboardHeight).toHaveBeenCalledWith({ + callback: expect.any(Function), + immediate: false, + }); + }); + + it('should update keyboard height when callback is invoked', () => { + mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => { + callback(300); + return { unsubscribe: mockUnsubscribe }; + }); + + const { result } = renderHook(() => useKeyboardHeight()); + + expect(result.current.keyboardHeight).toBe(300); + }); + }); + + describe('cleanup behavior', () => { + it('should call unsubscribe on unmount', () => { + const { unmount } = renderHook(() => useKeyboardHeight()); + + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('should resubscribe when immediate option changes', () => { + const { rerender } = renderHook(({ immediate }) => useKeyboardHeight({ immediate }), { + initialProps: { immediate: true }, + }); + + expect(mockSubscribeKeyboardHeight).toHaveBeenCalledTimes(1); + + rerender({ immediate: false }); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + expect(mockSubscribeKeyboardHeight).toHaveBeenCalledTimes(2); + }); + }); + + describe('keyboard height updates', () => { + it('should track keyboard height changes', () => { + let capturedCallback: ((height: number) => void) | null = null; + + mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => { + capturedCallback = callback; + return { unsubscribe: mockUnsubscribe }; + }); + + const { result } = renderHook(() => useKeyboardHeight()); + + expect(result.current.keyboardHeight).toBe(0); + + act(() => { + capturedCallback?.(250); + }); + + expect(result.current.keyboardHeight).toBe(250); + + act(() => { + capturedCallback?.(0); + }); + + expect(result.current.keyboardHeight).toBe(0); + }); + + it('should handle various keyboard heights', () => { + const heights = [0, 100, 250, 350, 500]; + + for (const expectedHeight of heights) { + mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => { + callback(expectedHeight); + return { unsubscribe: mockUnsubscribe }; + }); + + const { result } = renderHook(() => useKeyboardHeight()); + + expect(result.current.keyboardHeight).toBe(expectedHeight); + } + }); + }); + + describe('use cases', () => { + it('should provide keyboard height for bottom padding adjustment', () => { + mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => { + callback(300); + return { unsubscribe: mockUnsubscribe }; + }); + + const { result } = renderHook(() => useKeyboardHeight()); + + const paddingBottom = `${result.current.keyboardHeight}px`; + expect(paddingBottom).toBe('300px'); + }); + + it('should detect keyboard visibility', () => { + mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => { + callback(300); + return { unsubscribe: mockUnsubscribe }; + }); + + const { result } = renderHook(() => useKeyboardHeight()); + + const isKeyboardVisible = result.current.keyboardHeight > 0; + expect(isKeyboardVisible).toBe(true); + }); + + it('should detect keyboard hidden state', () => { + mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => { + callback(0); + return { unsubscribe: mockUnsubscribe }; + }); + + const { result } = renderHook(() => useKeyboardHeight()); + + const isKeyboardVisible = result.current.keyboardHeight > 0; + expect(isKeyboardVisible).toBe(false); + }); + }); + + describe('multiple instances', () => { + it('should allow multiple independent hook instances', () => { + let callback1: ((height: number) => void) | null = null; + let callback2: ((height: number) => void) | null = null; + + mockSubscribeKeyboardHeight + .mockImplementationOnce(({ callback }) => { + callback1 = callback; + return { unsubscribe: mockUnsubscribe }; + }) + .mockImplementationOnce(({ callback }) => { + callback2 = callback; + return { unsubscribe: mockUnsubscribe }; + }); + + const { result: result1 } = renderHook(() => useKeyboardHeight()); + const { result: result2 } = renderHook(() => useKeyboardHeight()); + + act(() => { + callback1?.(200); + callback2?.(200); + }); + + expect(result1.current.keyboardHeight).toBe(200); + expect(result2.current.keyboardHeight).toBe(200); + }); + }); +}); diff --git a/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts new file mode 100644 index 0000000..91e59c2 --- /dev/null +++ b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +import { subscribeKeyboardHeight } from '../../utils/keyboardHeight/subscribeKeyboardHeight.ts'; + +type UseKeyboardHeightOptions = { + /** + * If true, the hook will get the initial keyboard height on mount. + * @default true + */ + immediate?: boolean; +}; + +type UseKeyboardHeightResult = { + /** + * The current keyboard height in pixels. + */ + keyboardHeight: number; +}; + +/** + * React hook to track the on-screen keyboard height. + * + * Returns an object containing the current keyboard height in pixels, + * which updates automatically when the keyboard appears, disappears, or changes size. + * + * @param options - Configuration options + * @param options.immediate - If true, gets the initial keyboard height on mount (default: true) + * @returns An object containing the current keyboard height + * + * @example + * ```tsx + * function ChatInput() { + * const { keyboardHeight } = useKeyboardHeight(); + * + * return ( + *
+ * + *
+ * ); + * } + * ``` + */ +export function useKeyboardHeight(options: UseKeyboardHeightOptions = {}): UseKeyboardHeightResult { + const { immediate = true } = options; + + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect( + function subscribeToKeyboardHeight() { + const { unsubscribe } = subscribeKeyboardHeight({ + callback: setKeyboardHeight, + immediate, + }); + + return unsubscribe; + }, + [immediate] + ); + + return { keyboardHeight }; +} diff --git a/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.test.ts b/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.test.ts new file mode 100644 index 0000000..98d0b01 --- /dev/null +++ b/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { subscribeKeyboardHeight } from './subscribeKeyboardHeight.ts'; + +describe('subscribeKeyboardHeight', () => { + let mockVisualViewport: { + height: number; + offsetTop: number; + addEventListener: ReturnType; + removeEventListener: ReturnType; + }; + + beforeEach(() => { + vi.useFakeTimers(); + + mockVisualViewport = { + height: 500, + offsetTop: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + + Object.defineProperty(window, 'visualViewport', { + value: mockVisualViewport, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('should return an object with unsubscribe function when visualViewport is not available', () => { + Object.defineProperty(window, 'visualViewport', { + value: null, + writable: true, + configurable: true, + }); + + const callback = vi.fn(); + const { unsubscribe } = subscribeKeyboardHeight({ callback }); + + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); // should not throw + }); + + it('should add resize and scroll event listeners', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback }); + + expect(mockVisualViewport.addEventListener).toHaveBeenCalledTimes(2); + expect(mockVisualViewport.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(mockVisualViewport.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + }); + + it('should call callback with keyboard height on resize event', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 0 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + resizeHandler?.(); + + expect(callback).toHaveBeenCalledWith(300); + }); + + it('should call callback with keyboard height on scroll event', () => { + const callback = vi.fn(); + + // Use immediate to set lastHeight, then change height for scroll test + subscribeKeyboardHeight({ callback, immediate: true, throttleMs: 0 }); + vi.runAllTimers(); + + // Reset callback tracking + callback.mockClear(); + + // Change keyboard height + mockVisualViewport.height = 400; + + const scrollHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'scroll')?.[1]; + + scrollHandler?.(); + + expect(callback).toHaveBeenCalledWith(400); + }); + + it('should not call callback immediately by default', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should call callback immediately when immediate option is true', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, immediate: true }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(300); + }); + + it('should remove event listeners when unsubscribe is called', () => { + const callback = vi.fn(); + + const { unsubscribe } = subscribeKeyboardHeight({ callback }); + unsubscribe(); + + expect(mockVisualViewport.removeEventListener).toHaveBeenCalledTimes(2); + expect(mockVisualViewport.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(mockVisualViewport.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + }); + + it('should remove the same handler that was added', () => { + const callback = vi.fn(); + + const { unsubscribe } = subscribeKeyboardHeight({ callback }); + unsubscribe(); + + const addedResizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + const removedResizeHandler = mockVisualViewport.removeEventListener.mock.calls.find( + call => call[0] === 'resize' + )?.[1]; + + expect(addedResizeHandler).toBe(removedResizeHandler); + }); + + describe('throttle behavior', () => { + it('should throttle events by default (16ms)', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // First call - should invoke callback + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(300); + + // Change height and call again immediately - should be throttled + mockVisualViewport.height = 400; + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + + // Wait for throttle to clear + vi.advanceTimersByTime(16); + + // Now should invoke callback + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(400); + }); + + it('should respect custom throttle time', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 100 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // First call + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + + // Change height + mockVisualViewport.height = 400; + + // Wait 50ms - should still be throttled + vi.advanceTimersByTime(50); + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + + // Wait another 50ms (total 100ms) + vi.advanceTimersByTime(50); + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should handle throttleMs of 0 (no throttling)', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 0 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // First call + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + + // Change height and call immediately - should work with 0ms throttle + mockVisualViewport.height = 400; + vi.advanceTimersByTime(0); + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + describe('deduplication behavior', () => { + it('should skip callback when height has not changed', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 0 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + const scrollHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'scroll')?.[1]; + + // First resize call + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(300); + + // Scroll call with same height - should be skipped + scrollHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + + // Another resize with same height - should be skipped + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call callback when height changes after deduplication', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 0 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // First call + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(300); + + // Same height - skipped + vi.advanceTimersByTime(0); + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + + // Change height + mockVisualViewport.height = 600; + vi.advanceTimersByTime(0); + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(200); + }); + + it('should handle resize and scroll firing simultaneously with same value', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 0 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + const scrollHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'scroll')?.[1]; + + // Simulate simultaneous resize and scroll events + resizeHandler?.(); + scrollHandler?.(); + + // Only one callback should have been invoked due to deduplication + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(300); + }); + }); + + describe('rapid event firing', () => { + it('should handle rapid events correctly with throttling', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 16 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // Simulate rapid events (100 events) + for (let i = 0; i < 100; i++) { + resizeHandler?.(); + } + + // Should only call once due to throttling and same value + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should limit callbacks during rapid height changes', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, throttleMs: 50 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // First event + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(1); + + // Simulate rapid events with changing heights + for (let i = 0; i < 100; i++) { + mockVisualViewport.height = 500 - i; + resizeHandler?.(); + } + + // Should still be 1 due to throttling + expect(callback).toHaveBeenCalledTimes(1); + + // Wait for throttle to clear + vi.advanceTimersByTime(50); + + // Now trigger another event + mockVisualViewport.height = 200; + resizeHandler?.(); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(600); + }); + + it('should not exceed reasonable callback count during animation simulation', () => { + const callback = vi.fn(); + + // 50ms throttle + subscribeKeyboardHeight({ callback, throttleMs: 50 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // Simulate 500ms of animation with events every 10ms + // Without throttle: 50 events, With 50ms throttle: ~10 events max + for (let i = 0; i < 50; i++) { + mockVisualViewport.height = 500 - i * 6; // Gradually decrease viewport (keyboard appearing) + resizeHandler?.(); + vi.advanceTimersByTime(10); + } + + // With 50ms throttle, we should have at most 11 calls (500ms / 50ms + 1) + expect(callback.mock.calls.length).toBeLessThanOrEqual(11); + expect(callback.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('cleanup', () => { + it('should clear throttle timer on unsubscribe', () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + const callback = vi.fn(); + + const { unsubscribe } = subscribeKeyboardHeight({ callback, throttleMs: 100 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + // Trigger an event to start throttle timer + resizeHandler?.(); + + // Unsubscribe while throttle is active + unsubscribe(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.ts b/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.ts new file mode 100644 index 0000000..3a1a06a --- /dev/null +++ b/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.ts @@ -0,0 +1,123 @@ +import { isServer } from '../isServer.ts'; + +import { getKeyboardHeight } from './getKeyboardHeight.ts'; + +type SubscribeKeyboardHeightOptions = { + /** + * A function that will be called with the updated keyboard height in pixels. + */ + callback: (height: number) => void; + /** + * If true, the callback will be invoked immediately with the current keyboard height. + * @default false + */ + immediate?: boolean; + /** + * Throttle interval in milliseconds. + * Events within this interval will be ignored to improve performance. + * @default 16 (~60fps) + */ + throttleMs?: number; +}; + +type SubscribeKeyboardHeightResult = { + /** + * Unsubscribes all listeners and stops receiving keyboard height updates. + */ + unsubscribe: () => void; +}; + +/** + * Subscribes to changes in the on-screen keyboard height. + * + * The provided callback is invoked whenever the keyboard height may change, + * including when the keyboard appears, disappears, or changes size. + * + * Internally, this function listens to both `resize` and `scroll` events + * on the Visual Viewport: + * - `resize`: triggered when the visual viewport height changes + * - `scroll`: triggered when the visual viewport offset changes + * (important for iOS where the viewport can shift without resizing) + * + * Performance optimizations: + * - Throttled by default (16ms, ~60fps) to prevent excessive callback invocations + * - Skips callback when height hasn't changed (deduplication) + * + * @param options - Configuration options + * @param options.callback - A function that will be called with the updated keyboard height in pixels. + * @param options.immediate - If true, the callback will be invoked immediately with the current keyboard height. + * @param options.throttleMs - Throttle interval in milliseconds (default: 16ms). + * + * @returns An object containing the unsubscribe function. + * + * @example + * ```ts + * const { unsubscribe } = subscribeKeyboardHeight({ + * callback: (height) => { + * footer.style.paddingBottom = `${height}px`; + * }, + * immediate: true, + * }); + * + * // Later, when cleanup is needed + * unsubscribe(); + * ``` + */ +export function subscribeKeyboardHeight({ + callback, + immediate = false, + throttleMs = 16, +}: SubscribeKeyboardHeightOptions): SubscribeKeyboardHeightResult { + if (isServer()) { + return { unsubscribe: () => {} }; + } + + const visualViewport = window.visualViewport; + if (!visualViewport) { + return { unsubscribe: () => {} }; + } + + let lastHeight: number | null = null; + let throttleTimer: ReturnType | null = null; + + const handler = () => { + // Skip if throttled + if (throttleTimer != null) { + return; + } + + const currentHeight = getKeyboardHeight(); + + // Skip if height hasn't changed (deduplication) + if (lastHeight === currentHeight) { + return; + } + + lastHeight = currentHeight; + callback(currentHeight); + + // Start throttle timer + throttleTimer = setTimeout(() => { + throttleTimer = null; + }, throttleMs); + }; + + if (immediate) { + const currentHeight = getKeyboardHeight(); + lastHeight = currentHeight; + callback(currentHeight); + } + + visualViewport.addEventListener('resize', handler); + visualViewport.addEventListener('scroll', handler); + + return { + unsubscribe: () => { + visualViewport.removeEventListener('resize', handler); + visualViewport.removeEventListener('scroll', handler); + if (throttleTimer != null) { + clearTimeout(throttleTimer); + } + }, + }; +}