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
2 changes: 2 additions & 0 deletions packages/mobile/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
// Utils
export { isServer } from './utils/isServer.ts';
export { getKeyboardHeight } from './utils/keyboard/getKeyboardHeight.ts';
export { isKeyboardVisible } from './utils/keyboard/isKeyboardVisible.ts';
86 changes: 86 additions & 0 deletions packages/mobile/src/utils/keyboard/getKeyboardHeight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { getKeyboardHeight } from './getKeyboardHeight.ts';

describe('getKeyboardHeight', () => {
const originalVisualViewport = window.visualViewport;

afterEach(() => {
vi.restoreAllMocks();
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);
});
});
40 changes: 40 additions & 0 deletions packages/mobile/src/utils/keyboard/getKeyboardHeight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { isServer } from '../isServer.ts';

/**
* 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 {
if (isServer()) {
return 0;
}

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);
}
50 changes: 50 additions & 0 deletions packages/mobile/src/utils/keyboard/isKeyboardVisible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { getKeyboardHeight } from './getKeyboardHeight.ts';
import { isKeyboardVisible } from './isKeyboardVisible.ts';

vi.mock('./getKeyboardHeight.ts', () => ({
getKeyboardHeight: vi.fn(),
}));

const mockGetKeyboardHeight = vi.mocked(getKeyboardHeight);

describe('isKeyboardVisible', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('should return true when keyboard height is greater than 0', () => {
mockGetKeyboardHeight.mockReturnValue(300);

expect(isKeyboardVisible()).toBe(true);
});

it('should return false when keyboard height is 0', () => {
mockGetKeyboardHeight.mockReturnValue(0);

expect(isKeyboardVisible()).toBe(false);
});

it('should call getKeyboardHeight internally', () => {
mockGetKeyboardHeight.mockReturnValue(0);

isKeyboardVisible();

expect(mockGetKeyboardHeight).toHaveBeenCalledTimes(1);
});

describe('edge cases', () => {
it('should return true for small keyboard height', () => {
mockGetKeyboardHeight.mockReturnValue(1);

expect(isKeyboardVisible()).toBe(true);
});

it('should return true for large keyboard height', () => {
mockGetKeyboardHeight.mockReturnValue(500);

expect(isKeyboardVisible()).toBe(true);
});
});
});
29 changes: 29 additions & 0 deletions packages/mobile/src/utils/keyboard/isKeyboardVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getKeyboardHeight } from './getKeyboardHeight.ts';

/**
* Checks whether the on-screen keyboard is currently visible.
*
* This function uses `getKeyboardHeight()` internally and returns `true`
* if the keyboard height is greater than 0.
*
* @returns {boolean} `true` if the keyboard is visible, `false` otherwise.
*
* @example
* ```ts
* if (isKeyboardVisible()) {
* console.log('Keyboard is open');
* } else {
* console.log('Keyboard is closed');
* }
* ```
*
* @example
* ```ts
* // Conditionally show/hide elements based on keyboard visibility
* const showFloatingButton = !isKeyboardVisible();
* ```
*/
export function isKeyboardVisible(): boolean {
const keyboardHeight = getKeyboardHeight();
return keyboardHeight > 0;
}