Skip to content

Commit 19f3718

Browse files
author
sukyeong
committed
feat: implement subscribeKeyboardHeight and useKeyboardHeight function
1 parent 902482e commit 19f3718

File tree

4 files changed

+451
-0
lines changed

4 files changed

+451
-0
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { subscribeKeyboardHeight } from '../../utils/keyboardHeight/subscribeKeyboardHeight.ts';
5+
6+
import { useKeyboardHeight } from './useKeyboardHeight.ts';
7+
8+
vi.mock('@webview-kit/core', () => ({
9+
subscribeKeyboardHeight: vi.fn(),
10+
}));
11+
12+
const mockSubscribeKeyboardHeight = vi.mocked(subscribeKeyboardHeight);
13+
14+
describe('useKeyboardHeight', () => {
15+
let mockUnsubscribe: ReturnType<typeof vi.fn>;
16+
17+
beforeEach(() => {
18+
mockUnsubscribe = vi.fn();
19+
mockSubscribeKeyboardHeight.mockReturnValue({ unsubscribe: mockUnsubscribe });
20+
});
21+
22+
afterEach(() => {
23+
vi.clearAllMocks();
24+
});
25+
26+
describe('initial state', () => {
27+
it('should return 0 as initial keyboard height', () => {
28+
const { result } = renderHook(() => useKeyboardHeight());
29+
30+
expect(result.current).toBe(0);
31+
});
32+
});
33+
34+
describe('subscription behavior', () => {
35+
it('should call subscribeKeyboardHeight with immediate: true by default', () => {
36+
renderHook(() => useKeyboardHeight());
37+
38+
expect(mockSubscribeKeyboardHeight).toHaveBeenCalledWith({
39+
callback: expect.any(Function),
40+
immediate: true,
41+
});
42+
});
43+
44+
it('should call subscribeKeyboardHeight with immediate: false when specified', () => {
45+
renderHook(() => useKeyboardHeight({ immediate: false }));
46+
47+
expect(mockSubscribeKeyboardHeight).toHaveBeenCalledWith({
48+
callback: expect.any(Function),
49+
immediate: false,
50+
});
51+
});
52+
53+
it('should update keyboard height when callback is invoked', () => {
54+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
55+
callback(300);
56+
return { unsubscribe: mockUnsubscribe };
57+
});
58+
59+
const { result } = renderHook(() => useKeyboardHeight());
60+
61+
expect(result.current).toBe(300);
62+
});
63+
});
64+
65+
describe('cleanup behavior', () => {
66+
it('should call unsubscribe on unmount', () => {
67+
const { unmount } = renderHook(() => useKeyboardHeight());
68+
69+
expect(mockUnsubscribe).not.toHaveBeenCalled();
70+
71+
unmount();
72+
73+
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
74+
});
75+
76+
it('should resubscribe when immediate option changes', () => {
77+
const { rerender } = renderHook(({ immediate }) => useKeyboardHeight({ immediate }), {
78+
initialProps: { immediate: true },
79+
});
80+
81+
expect(mockSubscribeKeyboardHeight).toHaveBeenCalledTimes(1);
82+
83+
rerender({ immediate: false });
84+
85+
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
86+
expect(mockSubscribeKeyboardHeight).toHaveBeenCalledTimes(2);
87+
});
88+
});
89+
90+
describe('keyboard height updates', () => {
91+
it('should track keyboard height changes', () => {
92+
let capturedCallback: ((height: number) => void) | null = null;
93+
94+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
95+
capturedCallback = callback;
96+
return { unsubscribe: mockUnsubscribe };
97+
});
98+
99+
const { result } = renderHook(() => useKeyboardHeight());
100+
101+
expect(result.current).toBe(0);
102+
103+
act(() => {
104+
capturedCallback?.(250);
105+
});
106+
107+
expect(result.current).toBe(250);
108+
109+
act(() => {
110+
capturedCallback?.(0);
111+
});
112+
113+
expect(result.current).toBe(0);
114+
});
115+
116+
it('should handle various keyboard heights', () => {
117+
const heights = [0, 100, 250, 350, 500];
118+
119+
for (const expectedHeight of heights) {
120+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
121+
callback(expectedHeight);
122+
return { unsubscribe: mockUnsubscribe };
123+
});
124+
125+
const { result } = renderHook(() => useKeyboardHeight());
126+
127+
expect(result.current).toBe(expectedHeight);
128+
}
129+
});
130+
});
131+
132+
describe('use cases', () => {
133+
it('should provide keyboard height for bottom padding adjustment', () => {
134+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
135+
callback(300);
136+
return { unsubscribe: mockUnsubscribe };
137+
});
138+
139+
const { result } = renderHook(() => useKeyboardHeight());
140+
141+
const paddingBottom = `${result.current}px`;
142+
expect(paddingBottom).toBe('300px');
143+
});
144+
145+
it('should detect keyboard visibility', () => {
146+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
147+
callback(300);
148+
return { unsubscribe: mockUnsubscribe };
149+
});
150+
151+
const { result } = renderHook(() => useKeyboardHeight());
152+
153+
const isKeyboardVisible = result.current > 0;
154+
expect(isKeyboardVisible).toBe(true);
155+
});
156+
157+
it('should detect keyboard hidden state', () => {
158+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
159+
callback(0);
160+
return { unsubscribe: mockUnsubscribe };
161+
});
162+
163+
const { result } = renderHook(() => useKeyboardHeight());
164+
165+
const isKeyboardVisible = result.current > 0;
166+
expect(isKeyboardVisible).toBe(false);
167+
});
168+
});
169+
170+
describe('multiple instances', () => {
171+
it('should allow multiple independent hook instances', () => {
172+
let callback1: ((height: number) => void) | null = null;
173+
let callback2: ((height: number) => void) | null = null;
174+
175+
mockSubscribeKeyboardHeight
176+
.mockImplementationOnce(({ callback }) => {
177+
callback1 = callback;
178+
return { unsubscribe: mockUnsubscribe };
179+
})
180+
.mockImplementationOnce(({ callback }) => {
181+
callback2 = callback;
182+
return { unsubscribe: mockUnsubscribe };
183+
});
184+
185+
const { result: result1 } = renderHook(() => useKeyboardHeight());
186+
const { result: result2 } = renderHook(() => useKeyboardHeight());
187+
188+
act(() => {
189+
callback1?.(200);
190+
callback2?.(200);
191+
});
192+
193+
expect(result1.current).toBe(200);
194+
expect(result2.current).toBe(200);
195+
});
196+
});
197+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { subscribeKeyboardHeight } from '../../utils/keyboardHeight/subscribeKeyboardHeight.ts';
4+
5+
type UseKeyboardHeightOptions = {
6+
/**
7+
* If true, the hook will get the initial keyboard height on mount.
8+
* @default true
9+
*/
10+
immediate?: boolean;
11+
};
12+
13+
/**
14+
* React hook to track the on-screen keyboard height.
15+
*
16+
* Returns the current keyboard height in pixels, which updates automatically
17+
* when the keyboard appears, disappears, or changes size.
18+
*
19+
* @param options - Configuration options
20+
* @param options.immediate - If true, gets the initial keyboard height on mount (default: true)
21+
* @returns The current keyboard height in pixels
22+
*
23+
* @example
24+
* ```tsx
25+
* function ChatInput() {
26+
* const keyboardHeight = useKeyboardHeight();
27+
*
28+
* return (
29+
* <div style={{ paddingBottom: `${keyboardHeight}px` }}>
30+
* <input type="text" placeholder="Type a message..." />
31+
* </div>
32+
* );
33+
* }
34+
* ```
35+
*/
36+
export function useKeyboardHeight(options: UseKeyboardHeightOptions = {}): number {
37+
const { immediate = true } = options;
38+
39+
const [keyboardHeight, setKeyboardHeight] = useState(0);
40+
41+
useEffect(
42+
function subscribeToKeyboardHeight() {
43+
const { unsubscribe } = subscribeKeyboardHeight({
44+
callback: setKeyboardHeight,
45+
immediate,
46+
});
47+
48+
return unsubscribe;
49+
},
50+
[immediate]
51+
);
52+
53+
return keyboardHeight;
54+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { subscribeKeyboardHeight } from './subscribeKeyboardHeight.ts';
4+
5+
describe('subscribeKeyboardHeight', () => {
6+
let mockVisualViewport: {
7+
height: number;
8+
offsetTop: number;
9+
addEventListener: ReturnType<typeof vi.fn>;
10+
removeEventListener: ReturnType<typeof vi.fn>;
11+
};
12+
13+
beforeEach(() => {
14+
mockVisualViewport = {
15+
height: 500,
16+
offsetTop: 0,
17+
addEventListener: vi.fn(),
18+
removeEventListener: vi.fn(),
19+
};
20+
21+
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
22+
23+
Object.defineProperty(window, 'visualViewport', {
24+
value: mockVisualViewport,
25+
writable: true,
26+
configurable: true,
27+
});
28+
});
29+
30+
afterEach(() => {
31+
vi.restoreAllMocks();
32+
});
33+
34+
it('should return an object with unsubscribe function when visualViewport is not available', () => {
35+
Object.defineProperty(window, 'visualViewport', {
36+
value: null,
37+
writable: true,
38+
configurable: true,
39+
});
40+
41+
const callback = vi.fn();
42+
const { unsubscribe } = subscribeKeyboardHeight({ callback });
43+
44+
expect(typeof unsubscribe).toBe('function');
45+
unsubscribe(); // should not throw
46+
});
47+
48+
it('should add resize and scroll event listeners', () => {
49+
const callback = vi.fn();
50+
51+
subscribeKeyboardHeight({ callback });
52+
53+
expect(mockVisualViewport.addEventListener).toHaveBeenCalledTimes(2);
54+
expect(mockVisualViewport.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
55+
expect(mockVisualViewport.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
56+
});
57+
58+
it('should call callback with keyboard height on resize event', () => {
59+
const callback = vi.fn();
60+
61+
subscribeKeyboardHeight({ callback });
62+
63+
const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1];
64+
65+
resizeHandler?.();
66+
67+
expect(callback).toHaveBeenCalledWith(300);
68+
});
69+
70+
it('should call callback with keyboard height on scroll event', () => {
71+
const callback = vi.fn();
72+
73+
subscribeKeyboardHeight({ callback });
74+
75+
const scrollHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'scroll')?.[1];
76+
77+
scrollHandler?.();
78+
79+
expect(callback).toHaveBeenCalledWith(300);
80+
});
81+
82+
it('should not call callback immediately by default', () => {
83+
const callback = vi.fn();
84+
85+
subscribeKeyboardHeight({ callback });
86+
87+
expect(callback).not.toHaveBeenCalled();
88+
});
89+
90+
it('should call callback immediately when immediate option is true', () => {
91+
const callback = vi.fn();
92+
93+
subscribeKeyboardHeight({ callback, immediate: true });
94+
95+
expect(callback).toHaveBeenCalledTimes(1);
96+
expect(callback).toHaveBeenCalledWith(300);
97+
});
98+
99+
it('should remove event listeners when unsubscribe is called', () => {
100+
const callback = vi.fn();
101+
102+
const { unsubscribe } = subscribeKeyboardHeight({ callback });
103+
unsubscribe();
104+
105+
expect(mockVisualViewport.removeEventListener).toHaveBeenCalledTimes(2);
106+
expect(mockVisualViewport.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
107+
expect(mockVisualViewport.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
108+
});
109+
110+
it('should remove the same handler that was added', () => {
111+
const callback = vi.fn();
112+
113+
const { unsubscribe } = subscribeKeyboardHeight({ callback });
114+
unsubscribe();
115+
116+
const addedResizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1];
117+
const removedResizeHandler = mockVisualViewport.removeEventListener.mock.calls.find(
118+
call => call[0] === 'resize'
119+
)?.[1];
120+
121+
expect(addedResizeHandler).toBe(removedResizeHandler);
122+
});
123+
});

0 commit comments

Comments
 (0)