Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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);
});
});
});
54 changes: 54 additions & 0 deletions packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts
Original file line number Diff line number Diff line change
@@ -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 (
* <div style={{ paddingBottom: `${keyboardHeight}px` }}>
* <input type="text" placeholder="Type a message..." />
* </div>
* );
* }
* ```
*/
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;
}
Loading