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 00000000..a6be16de --- /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('@webview-kit/core', () => ({ + 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).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).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).toBe(0); + + act(() => { + capturedCallback?.(250); + }); + + expect(result.current).toBe(250); + + act(() => { + capturedCallback?.(0); + }); + + expect(result.current).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).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}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 > 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 > 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).toBe(200); + expect(result2.current).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 00000000..d8f3816a --- /dev/null +++ b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts @@ -0,0 +1,54 @@ +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; +}; + +/** + * React hook to track the on-screen keyboard height. + * + * Returns 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 The current keyboard height in pixels + * + * @example + * ```tsx + * function ChatInput() { + * const keyboardHeight = useKeyboardHeight(); + * + * return ( + *
+ * + *
+ * ); + * } + * ``` + */ +export function useKeyboardHeight(options: UseKeyboardHeightOptions = {}): number { + 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/hooks/useAvoidKeyboard.test.ts b/packages/mobile/src/hooks/useAvoidKeyboard.test.ts new file mode 100644 index 00000000..86bd9176 --- /dev/null +++ b/packages/mobile/src/hooks/useAvoidKeyboard.test.ts @@ -0,0 +1,171 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAvoidKeyboard } from './useAvoidKeyboard.ts'; + +// Mock useKeyboardHeight hook +vi.mock('./keyboardHeight/useKeyboardHeight.ts', () => ({ + useKeyboardHeight: vi.fn(() => 0), +})); + +// Get reference to the mocked function +import { useKeyboardHeight } from './keyboardHeight/useKeyboardHeight.ts'; +const mockUseKeyboardHeight = vi.mocked(useKeyboardHeight); + +describe('useAvoidKeyboard', () => { + beforeEach(() => { + mockUseKeyboardHeight.mockReturnValue(0); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state', () => { + it('should return initial style with keyboard hidden', () => { + const { result } = renderHook(() => useAvoidKeyboard()); + + expect(result.current.style).toEqual({ + transform: 'translateY(0px)', + transition: 'transform 200ms ease-out', + }); + }); + }); + + describe('style generation', () => { + it('should generate correct transform when keyboard is visible', () => { + mockUseKeyboardHeight.mockReturnValue(300); + + const { result } = renderHook(() => useAvoidKeyboard()); + + expect(result.current.style).toEqual({ + transform: 'translateY(-300px)', + transition: 'transform 200ms ease-out', + }); + }); + + it('should include safeAreaBottom in transform calculation', () => { + mockUseKeyboardHeight.mockReturnValue(300); + + const { result } = renderHook(() => useAvoidKeyboard({ safeAreaBottom: 20 })); + + expect(result.current.style).toEqual({ + transform: 'translateY(-320px)', + transition: 'transform 200ms ease-out', + }); + }); + + it('should apply custom transition duration', () => { + const { result } = renderHook(() => useAvoidKeyboard({ transitionDuration: 300 })); + + expect(result.current.style.transition).toBe('transform 300ms ease-out'); + }); + + it('should apply custom transition timing function', () => { + const { result } = renderHook(() => + useAvoidKeyboard({ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)' }) + ); + + expect(result.current.style.transition).toBe('transform 200ms cubic-bezier(0.4, 0, 0.2, 1)'); + }); + + it('should apply all custom options together', () => { + mockUseKeyboardHeight.mockReturnValue(250); + + const { result } = renderHook(() => + useAvoidKeyboard({ + safeAreaBottom: 30, + transitionDuration: 150, + transitionTimingFunction: 'linear', + }) + ); + + expect(result.current.style).toEqual({ + transform: 'translateY(-280px)', + transition: 'transform 150ms linear', + }); + }); + }); + + describe('keyboard height updates', () => { + it('should update style when keyboard height changes', () => { + const { result, rerender } = renderHook(() => useAvoidKeyboard()); + + expect(result.current.style.transform).toBe('translateY(0px)'); + + mockUseKeyboardHeight.mockReturnValue(350); + rerender(); + + expect(result.current.style.transform).toBe('translateY(-350px)'); + + mockUseKeyboardHeight.mockReturnValue(0); + rerender(); + + expect(result.current.style.transform).toBe('translateY(0px)'); + }); + }); + + describe('immediate option', () => { + it('should pass immediate: true by default to useKeyboardHeight', () => { + renderHook(() => useAvoidKeyboard()); + + expect(mockUseKeyboardHeight).toHaveBeenCalledWith({ immediate: true }); + }); + + it('should pass immediate: false when specified', () => { + renderHook(() => useAvoidKeyboard({ immediate: false })); + + expect(mockUseKeyboardHeight).toHaveBeenCalledWith({ immediate: false }); + }); + }); + + describe('use cases', () => { + it('should provide style for fixed bottom CTA', () => { + mockUseKeyboardHeight.mockReturnValue(300); + + const { result } = renderHook(() => useAvoidKeyboard()); + + const elementStyle = { + position: 'fixed' as const, + bottom: 0, + left: 0, + right: 0, + ...result.current.style, + }; + + expect(elementStyle.transform).toBe('translateY(-300px)'); + expect(elementStyle.transition).toBe('transform 200ms ease-out'); + }); + + it('should handle safe area with safeAreaBottom', () => { + mockUseKeyboardHeight.mockReturnValue(300); + + const { result } = renderHook(() => useAvoidKeyboard({ safeAreaBottom: 34 })); + + expect(result.current.style.transform).toBe('translateY(-334px)'); + }); + }); + + describe('style memoization', () => { + it('should return same style object when values do not change', () => { + const { result, rerender } = renderHook(() => useAvoidKeyboard()); + + const firstStyle = result.current.style; + + rerender(); + + expect(result.current.style).toBe(firstStyle); + }); + + it('should return new style object when keyboard height changes', () => { + const { result, rerender } = renderHook(() => useAvoidKeyboard()); + + const firstStyle = result.current.style; + + mockUseKeyboardHeight.mockReturnValue(100); + rerender(); + + expect(result.current.style).not.toBe(firstStyle); + }); + }); +}); diff --git a/packages/mobile/src/hooks/useAvoidKeyboard.ts b/packages/mobile/src/hooks/useAvoidKeyboard.ts new file mode 100644 index 00000000..352999ea --- /dev/null +++ b/packages/mobile/src/hooks/useAvoidKeyboard.ts @@ -0,0 +1,114 @@ +import type { CSSProperties } from 'react'; +import { useMemo } from 'react'; + +import { useKeyboardHeight } from './keyboardHeight/useKeyboardHeight.ts'; + +type UseAvoidKeyboardOptions = { + /** + * Base bottom offset in pixels when keyboard is hidden. + * @default 0 + */ + safeAreaBottom?: number; + /** + * Transition duration in milliseconds for smooth animation. + * @default 200 + */ + transitionDuration?: number; + /** + * Transition timing function for the animation. + * @default 'ease-out' + */ + transitionTimingFunction?: CSSProperties['transitionTimingFunction']; + /** + * If true, the hook will get the initial keyboard height on mount. + * @default true + */ + immediate?: boolean; +}; + +type UseAvoidKeyboardResult = { + /** + * CSS style object to apply to the fixed bottom element. + * Contains transform and transition properties. + */ + style: CSSProperties; +}; + +/** + * React hook to help fixed-bottom elements avoid the on-screen keyboard. + * + * Returns an object containing a CSS style that can be applied to position:fixed elements + * to smoothly move them above the keyboard when it appears. + * + * @param options - Configuration options + * @param options.safeAreaBottom - Base bottom offset in pixels when keyboard is hidden (default: 0) + * @param options.transitionDuration - Transition duration in milliseconds (default: 200) + * @param options.transitionTimingFunction - Transition timing function (default: 'ease-out') + * @param options.immediate - If true, gets the initial keyboard height on mount (default: true) + * + * @returns An object containing the style property + * + * @example + * ```tsx + * function FixedBottomCTA() { + * const { style } = useAvoidKeyboard(); + * + * return ( + *
+ * + *
+ * ); + * } + * ``` + * + * @example + * ```tsx + * // With safe area bottom offset (e.g., for iPhone home indicator) + * function FixedBottomCTA() { + * const { style } = useAvoidKeyboard({ safeAreaBottom: 34 }); + * + * return ( + *
+ * + *
+ * ); + * } + * ``` + */ +export function useAvoidKeyboard(options: UseAvoidKeyboardOptions = {}): UseAvoidKeyboardResult { + const { + safeAreaBottom = 0, + transitionDuration = 200, + transitionTimingFunction = 'ease-out', + immediate = true, + } = options; + + const keyboardHeight = useKeyboardHeight({ immediate }); + + const style = useMemo(() => { + const translateY = -(keyboardHeight + safeAreaBottom); + + return { + transform: `translateY(${translateY}px)`, + transition: `transform ${transitionDuration}ms ${transitionTimingFunction}`, + }; + }, [keyboardHeight, safeAreaBottom, transitionDuration, transitionTimingFunction]); + + return { style }; +} diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts new file mode 100644 index 00000000..72ccabd8 --- /dev/null +++ b/packages/mobile/src/index.ts @@ -0,0 +1 @@ +export { useAvoidKeyboard } from './hooks/useAvoidKeyboard.ts'; diff --git a/packages/mobile/src/utils/device/device.test.ts b/packages/mobile/src/utils/device/device.test.ts new file mode 100644 index 00000000..d202fac9 --- /dev/null +++ b/packages/mobile/src/utils/device/device.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { isAndroid, isIOS } from './device.ts'; + +describe('device utils', () => { + it('should detect iOS', () => { + expect(isIOS('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')).toBe(true); + }); + + it('should detect iPadOS (MacIntel + touch)', () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + + expect(isIOS('Mozilla/5.0 (Macintosh; Intel Mac OS X)')).toBe(true); + }); +}); + +it('should detect Android', () => { + expect(isAndroid('Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120')).toBe(true); +}); diff --git a/packages/mobile/src/utils/device/device.ts b/packages/mobile/src/utils/device/device.ts new file mode 100644 index 00000000..3d551da1 --- /dev/null +++ b/packages/mobile/src/utils/device/device.ts @@ -0,0 +1,28 @@ +/** + * Detects whether the current device is running iOS or iPadOS. + * + * Notes on platform inconsistencies: + * - Prior to iPadOS 13, iPads reported their platform as "iPad" (or matched /iPad/ in UA). + * - Starting from iPadOS 13, Apple changed the platform string to "MacIntel" + * to make websites treat iPadOS as desktop-class Safari. + * However, these devices still expose multi-touch capabilities. + */ +export function isIOS(userAgent: string = navigator.userAgent): boolean { + const platform = navigator.platform; + const maxTouchPoints = navigator.maxTouchPoints; + + const matchesClassicIOS = /iPhone|iPad|iPod/i.test(userAgent); + const matchesModernIPad = platform === 'MacIntel' && typeof maxTouchPoints === 'number' && maxTouchPoints > 1; + + return matchesClassicIOS || matchesModernIPad; +} + +/** + * Detects whether the current device is running Android. + * + * Notes: + * - All Android browsers include the token "Android" in the user agent. + */ +export function isAndroid(userAgent: string = navigator.userAgent): boolean { + return /Android/i.test(userAgent); +} diff --git a/packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.test.ts b/packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.test.ts new file mode 100644 index 00000000..24701936 --- /dev/null +++ b/packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getKeyboardHeight } from './getKeyboardHeight.ts'; + +describe('getKeyboardHeight', () => { + const originalVisualViewport = window.visualViewport; + + afterEach(() => { + Object.defineProperty(window, 'visualViewport', { + value: originalVisualViewport, + writable: true, + configurable: true, + }); + }); + + it('should return 0 when visualViewport is not available', () => { + Object.defineProperty(window, 'visualViewport', { + value: null, + writable: true, + configurable: true, + }); + + expect(getKeyboardHeight()).toBe(0); + }); + + it('should return keyboard height when keyboard is visible', () => { + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + + Object.defineProperty(window, 'visualViewport', { + value: { + height: 500, + offsetTop: 0, + }, + writable: true, + configurable: true, + }); + + expect(getKeyboardHeight()).toBe(300); + }); + + it('should account for offsetTop in iOS behavior', () => { + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + + Object.defineProperty(window, 'visualViewport', { + value: { + height: 450, + offsetTop: 50, + }, + writable: true, + configurable: true, + }); + + expect(getKeyboardHeight()).toBe(300); + }); + + it('should return 0 when keyboard is not visible', () => { + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + + Object.defineProperty(window, 'visualViewport', { + value: { + height: 800, + offsetTop: 0, + }, + writable: true, + configurable: true, + }); + + expect(getKeyboardHeight()).toBe(0); + }); + + it('should return 0 when calculated height is negative', () => { + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + + Object.defineProperty(window, 'visualViewport', { + value: { + height: 900, + offsetTop: 0, + }, + writable: true, + configurable: true, + }); + + expect(getKeyboardHeight()).toBe(0); + }); +}); diff --git a/packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.ts b/packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.ts new file mode 100644 index 00000000..eca157bb --- /dev/null +++ b/packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.ts @@ -0,0 +1,34 @@ +/** + * Returns the current on-screen keyboard height in pixels. + * + * This function uses the Visual Viewport API to calculate the keyboard height. + * It assumes a modern environment where Visual Viewport is supported + * (Safari / WKWebView 14+, Chrome / Android WebView 80+). + * + * The keyboard height is computed as: + * window.innerHeight - visualViewport.height - visualViewport.offsetTop + * + * The subtraction of `offsetTop` is required to correctly handle iOS behavior + * where the visual viewport may shift vertically when the keyboard appears. + * + * @returns {number} The keyboard height in pixels. Returns 0 if the keyboard + * is not visible. + * + * @example + * ```ts + * const height = getKeyboardHeight(); + * + * if (height > 0) { + * footer.style.paddingBottom = `${height}px`; + * } + * ``` + */ +export function getKeyboardHeight(): number { + const visualViewport = window.visualViewport; + if (visualViewport == null) { + // Defensive guard; not expected to run in supported environments + return 0; + } + const height = window.innerHeight - visualViewport.height - visualViewport.offsetTop; + return Math.max(0, height); +} 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 00000000..f63bccb0 --- /dev/null +++ b/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.test.ts @@ -0,0 +1,123 @@ +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(() => { + 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(); + }); + + 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 }); + + 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(); + + subscribeKeyboardHeight({ callback }); + + const scrollHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'scroll')?.[1]; + + scrollHandler?.(); + + expect(callback).toHaveBeenCalledWith(300); + }); + + 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); + }); +}); diff --git a/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.ts b/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.ts new file mode 100644 index 00000000..9030012c --- /dev/null +++ b/packages/mobile/src/utils/keyboardHeight/subscribeKeyboardHeight.ts @@ -0,0 +1,77 @@ +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; +}; + +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) + * + * @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. + * + * @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, +}: SubscribeKeyboardHeightOptions): SubscribeKeyboardHeightResult { + const handler = () => callback(getKeyboardHeight()); + + const visualViewport = window.visualViewport; + if (!visualViewport) { + return { unsubscribe: () => {} }; + } + + if (immediate) { + handler(); + } + + visualViewport.addEventListener('resize', handler); + visualViewport.addEventListener('scroll', handler); + + return { + unsubscribe: () => { + visualViewport.removeEventListener('resize', handler); + visualViewport.removeEventListener('scroll', handler); + }, + }; +} diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json index 41818e82..f0b6463e 100644 --- a/packages/mobile/tsconfig.json +++ b/packages/mobile/tsconfig.json @@ -15,6 +15,6 @@ "@react-simplikit/mobile": ["./src/index.ts"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "utils/**/*"], "exclude": ["node_modules", "dist"] }