Skip to content

Commit 644c020

Browse files
author
sukyeong
committed
feat(mobile): implement isKeyboardVisible function
1 parent b7cd8e4 commit 644c020

File tree

6 files changed

+357
-1
lines changed

6 files changed

+357
-1
lines changed
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { subscribeKeyboardHeight } from '../utils/keyboard/subscribeKeyboardHeight.ts';
5+
6+
import { useAvoidKeyboard } from './useAvoidKeyboard.ts';
7+
8+
vi.mock('../../utils/keyboardHeight/subscribeKeyboardHeight.ts', () => ({
9+
subscribeKeyboardHeight: vi.fn(),
10+
}));
11+
12+
const mockSubscribeKeyboardHeight = vi.mocked(subscribeKeyboardHeight);
13+
14+
describe('useAvoidKeyboard', () => {
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 initial state with keyboard hidden', () => {
28+
const { result } = renderHook(() => useAvoidKeyboard());
29+
30+
expect(result.current.keyboardHeight).toBe(0);
31+
expect(result.current.isKeyboardVisible).toBe(false);
32+
expect(result.current.style).toEqual({
33+
transform: 'translateY(0px)',
34+
transition: 'transform 200ms ease-out',
35+
});
36+
});
37+
});
38+
39+
describe('style generation', () => {
40+
it('should generate correct transform when keyboard is visible', () => {
41+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
42+
callback(300);
43+
return { unsubscribe: mockUnsubscribe };
44+
});
45+
46+
const { result } = renderHook(() => useAvoidKeyboard());
47+
48+
expect(result.current.style).toEqual({
49+
transform: 'translateY(-300px)',
50+
transition: 'transform 200ms ease-out',
51+
});
52+
});
53+
54+
it('should include baseBottom in transform calculation', () => {
55+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
56+
callback(300);
57+
return { unsubscribe: mockUnsubscribe };
58+
});
59+
60+
const { result } = renderHook(() => useAvoidKeyboard({ baseBottom: 20 }));
61+
62+
expect(result.current.style).toEqual({
63+
transform: 'translateY(-320px)',
64+
transition: 'transform 200ms ease-out',
65+
});
66+
});
67+
68+
it('should apply custom transition duration', () => {
69+
const { result } = renderHook(() => useAvoidKeyboard({ transitionDuration: 300 }));
70+
71+
expect(result.current.style.transition).toBe('transform 300ms ease-out');
72+
});
73+
74+
it('should apply custom transition timing function', () => {
75+
const { result } = renderHook(() =>
76+
useAvoidKeyboard({ transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)' })
77+
);
78+
79+
expect(result.current.style.transition).toBe('transform 200ms cubic-bezier(0.4, 0, 0.2, 1)');
80+
});
81+
82+
it('should apply all custom options together', () => {
83+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
84+
callback(250);
85+
return { unsubscribe: mockUnsubscribe };
86+
});
87+
88+
const { result } = renderHook(() =>
89+
useAvoidKeyboard({
90+
baseBottom: 30,
91+
transitionDuration: 150,
92+
transitionTimingFunction: 'linear',
93+
})
94+
);
95+
96+
expect(result.current.style).toEqual({
97+
transform: 'translateY(-280px)',
98+
transition: 'transform 150ms linear',
99+
});
100+
});
101+
});
102+
103+
describe('keyboard visibility', () => {
104+
it('should return isKeyboardVisible as true when keyboard height > 0', () => {
105+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
106+
callback(100);
107+
return { unsubscribe: mockUnsubscribe };
108+
});
109+
110+
const { result } = renderHook(() => useAvoidKeyboard());
111+
112+
expect(result.current.isKeyboardVisible).toBe(true);
113+
});
114+
115+
it('should return isKeyboardVisible as false when keyboard height is 0', () => {
116+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
117+
callback(0);
118+
return { unsubscribe: mockUnsubscribe };
119+
});
120+
121+
const { result } = renderHook(() => useAvoidKeyboard());
122+
123+
expect(result.current.isKeyboardVisible).toBe(false);
124+
});
125+
});
126+
127+
describe('keyboard height updates', () => {
128+
it('should update style when keyboard height changes', () => {
129+
let capturedCallback: ((height: number) => void) | null = null;
130+
131+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
132+
capturedCallback = callback;
133+
return { unsubscribe: mockUnsubscribe };
134+
});
135+
136+
const { result } = renderHook(() => useAvoidKeyboard());
137+
138+
expect(result.current.style.transform).toBe('translateY(0px)');
139+
expect(result.current.isKeyboardVisible).toBe(false);
140+
141+
act(() => {
142+
capturedCallback?.(350);
143+
});
144+
145+
expect(result.current.style.transform).toBe('translateY(-350px)');
146+
expect(result.current.isKeyboardVisible).toBe(true);
147+
148+
act(() => {
149+
capturedCallback?.(0);
150+
});
151+
152+
expect(result.current.style.transform).toBe('translateY(0px)');
153+
expect(result.current.isKeyboardVisible).toBe(false);
154+
});
155+
});
156+
157+
describe('immediate option', () => {
158+
it('should pass immediate: true by default to useKeyboardHeight', () => {
159+
renderHook(() => useAvoidKeyboard());
160+
161+
expect(mockSubscribeKeyboardHeight).toHaveBeenCalledWith({
162+
callback: expect.any(Function),
163+
immediate: true,
164+
});
165+
});
166+
167+
it('should pass immediate: false when specified', () => {
168+
renderHook(() => useAvoidKeyboard({ immediate: false }));
169+
170+
expect(mockSubscribeKeyboardHeight).toHaveBeenCalledWith({
171+
callback: expect.any(Function),
172+
immediate: false,
173+
});
174+
});
175+
});
176+
177+
describe('cleanup behavior', () => {
178+
it('should clean up subscription on unmount', () => {
179+
const { unmount } = renderHook(() => useAvoidKeyboard());
180+
181+
expect(mockUnsubscribe).not.toHaveBeenCalled();
182+
183+
unmount();
184+
185+
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
186+
});
187+
});
188+
189+
describe('use cases', () => {
190+
it('should provide style for fixed bottom CTA', () => {
191+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
192+
callback(300);
193+
return { unsubscribe: mockUnsubscribe };
194+
});
195+
196+
const { result } = renderHook(() => useAvoidKeyboard());
197+
198+
const elementStyle = {
199+
position: 'fixed' as const,
200+
bottom: 0,
201+
left: 0,
202+
right: 0,
203+
...result.current.style,
204+
};
205+
206+
expect(elementStyle.transform).toBe('translateY(-300px)');
207+
expect(elementStyle.transition).toBe('transform 200ms ease-out');
208+
});
209+
210+
it('should handle safe area with baseBottom', () => {
211+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
212+
callback(300);
213+
return { unsubscribe: mockUnsubscribe };
214+
});
215+
216+
const safeAreaBottom = 34;
217+
const { result } = renderHook(() => useAvoidKeyboard({ baseBottom: safeAreaBottom }));
218+
219+
expect(result.current.style.transform).toBe('translateY(-334px)');
220+
});
221+
222+
it('should conditionally render based on keyboard visibility', () => {
223+
let capturedCallback: ((height: number) => void) | null = null;
224+
225+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
226+
capturedCallback = callback;
227+
return { unsubscribe: mockUnsubscribe };
228+
});
229+
230+
const { result } = renderHook(() => useAvoidKeyboard());
231+
232+
// Initial state: keyboard hidden, show element normally
233+
expect(result.current.isKeyboardVisible).toBe(false);
234+
235+
// Keyboard appears
236+
act(() => {
237+
capturedCallback?.(300);
238+
});
239+
240+
// Element should move up
241+
expect(result.current.isKeyboardVisible).toBe(true);
242+
expect(result.current.keyboardHeight).toBe(300);
243+
});
244+
});
245+
246+
describe('style memoization', () => {
247+
it('should return same style object when values do not change', () => {
248+
const { result, rerender } = renderHook(() => useAvoidKeyboard());
249+
250+
const firstStyle = result.current.style;
251+
252+
rerender();
253+
254+
expect(result.current.style).toBe(firstStyle);
255+
});
256+
257+
it('should return new style object when keyboard height changes', () => {
258+
let capturedCallback: ((height: number) => void) | null = null;
259+
260+
mockSubscribeKeyboardHeight.mockImplementation(({ callback }) => {
261+
capturedCallback = callback;
262+
return { unsubscribe: mockUnsubscribe };
263+
});
264+
265+
const { result } = renderHook(() => useAvoidKeyboard());
266+
267+
const firstStyle = result.current.style;
268+
269+
act(() => {
270+
capturedCallback?.(100);
271+
});
272+
273+
expect(result.current.style).not.toBe(firstStyle);
274+
});
275+
});
276+
});

packages/mobile/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// Utils
22
export { isServer } from './utils/isServer.ts';
3-
export { getKeyboardHeight } from './utils/keyboardHeight/getKeyboardHeight.ts';
3+
export { getKeyboardHeight } from './utils/keyboard/getKeyboardHeight.ts';
4+
export { isKeyboardVisible } from './utils/keyboard/isKeyboardVisible.ts';

packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.test.ts renamed to packages/mobile/src/utils/keyboard/getKeyboardHeight.test.ts

File renamed without changes.

packages/mobile/src/utils/keyboardHeight/getKeyboardHeight.ts renamed to packages/mobile/src/utils/keyboard/getKeyboardHeight.ts

File renamed without changes.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { getKeyboardHeight } from './getKeyboardHeight.ts';
4+
import { isKeyboardVisible } from './isKeyboardVisible.ts';
5+
6+
vi.mock('./getKeyboardHeight.ts', () => ({
7+
getKeyboardHeight: vi.fn(),
8+
}));
9+
10+
const mockGetKeyboardHeight = vi.mocked(getKeyboardHeight);
11+
12+
describe('isKeyboardVisible', () => {
13+
afterEach(() => {
14+
vi.clearAllMocks();
15+
});
16+
17+
it('should return true when keyboard height is greater than 0', () => {
18+
mockGetKeyboardHeight.mockReturnValue(300);
19+
20+
expect(isKeyboardVisible()).toBe(true);
21+
});
22+
23+
it('should return false when keyboard height is 0', () => {
24+
mockGetKeyboardHeight.mockReturnValue(0);
25+
26+
expect(isKeyboardVisible()).toBe(false);
27+
});
28+
29+
it('should call getKeyboardHeight internally', () => {
30+
mockGetKeyboardHeight.mockReturnValue(0);
31+
32+
isKeyboardVisible();
33+
34+
expect(mockGetKeyboardHeight).toHaveBeenCalledTimes(1);
35+
});
36+
37+
describe('edge cases', () => {
38+
it('should return true for small keyboard height', () => {
39+
mockGetKeyboardHeight.mockReturnValue(1);
40+
41+
expect(isKeyboardVisible()).toBe(true);
42+
});
43+
44+
it('should return true for large keyboard height', () => {
45+
mockGetKeyboardHeight.mockReturnValue(500);
46+
47+
expect(isKeyboardVisible()).toBe(true);
48+
});
49+
});
50+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { getKeyboardHeight } from './getKeyboardHeight.ts';
2+
3+
/**
4+
* Checks whether the on-screen keyboard is currently visible.
5+
*
6+
* This function uses `getKeyboardHeight()` internally and returns `true`
7+
* if the keyboard height is greater than 0.
8+
*
9+
* @returns {boolean} `true` if the keyboard is visible, `false` otherwise.
10+
*
11+
* @example
12+
* ```ts
13+
* if (isKeyboardVisible()) {
14+
* console.log('Keyboard is open');
15+
* } else {
16+
* console.log('Keyboard is closed');
17+
* }
18+
* ```
19+
*
20+
* @example
21+
* ```ts
22+
* // Conditionally show/hide elements based on keyboard visibility
23+
* const showFloatingButton = !isKeyboardVisible();
24+
* ```
25+
*/
26+
export function isKeyboardVisible(): boolean {
27+
const keyboardHeight = getKeyboardHeight();
28+
return keyboardHeight > 0;
29+
}

0 commit comments

Comments
 (0)