Skip to content

Commit 7313ca0

Browse files
committed
refactor: split implementation into different files, move inline functions into re-usable, de-duplicate types
1 parent f097bc5 commit 7313ca0

16 files changed

Lines changed: 344 additions & 180 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { useChatKeyboard } from "./useChatKeyboard";
2-
export type { KeyboardLiftBehavior } from "./useChatKeyboard";
2+
export type { KeyboardLiftBehavior } from "./useChatKeyboard/types";

src/components/KeyboardChatScrollView/types.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { AnimatedScrollViewComponent } from "../ScrollViewWithBottomPadding";
2+
import type { KeyboardLiftBehavior } from "./useChatKeyboard/types";
23
import type { ScrollViewProps } from "react-native";
34

4-
type KeyboardLiftBehavior = "always" | "whenAtEnd" | "persistent" | "never";
5-
65
export type KeyboardChatScrollViewProps = {
76
/** Custom component for `ScrollView`. Default is `ScrollView`. */
87
ScrollViewComponent?: AnimatedScrollViewComponent;

src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,31 @@ export const mockSize = { value: { width: 390, height: 2000 } };
1919
export const KEYBOARD = 300;
2020
export const mockScrollTo = jest.fn();
2121

22+
/**
23+
* Linear interpolate mock matching Reanimated's `interpolate` signature.
24+
*
25+
* @param value - The input value to interpolate.
26+
* @param input - Input range `[min, max]`.
27+
* @param output - Output range `[min, max]`.
28+
* @returns The interpolated value.
29+
* @example mockInterpolate(150, [0, 300], [0, 250]); // 125
30+
*/
31+
export function mockInterpolate(
32+
value: number,
33+
input: number[],
34+
output: number[],
35+
): number {
36+
"worklet";
37+
38+
if (input[1] === 0) {
39+
return 0;
40+
}
41+
42+
const progress = (value - input[0]) / (input[1] - input[0]);
43+
44+
return output[0] + progress * (output[1] - output[0]);
45+
}
46+
2247
/** Reset mock scroll state to defaults. */
2348
export function reset() {
2449
mockOffset.value = 0;
@@ -37,46 +62,43 @@ export function setupBeforeEach() {
3762
jest.doMock("react-native-reanimated", () => ({
3863
...require("react-native-reanimated/mock"),
3964
scrollTo: mockScrollTo,
40-
interpolate: (value: number, input: number[], output: number[]) => {
41-
"worklet";
42-
43-
if (input[1] === 0) {
44-
return 0;
45-
}
46-
47-
const progress = (value - input[0]) / (input[1] - input[0]);
48-
49-
return output[0] + progress * (output[1] - output[0]);
50-
},
65+
interpolate: mockInterpolate,
5166
}));
5267

5368
reset();
5469
mockScrollTo.mockClear();
5570
}
5671

72+
type RenderOptions = Omit<
73+
Parameters<typeof useChatKeyboard>[1],
74+
"freeze" | "offset"
75+
> & {
76+
freeze?: boolean;
77+
offset?: number;
78+
};
79+
5780
/**
58-
* Render the hook with optional freeze (defaults to `false`).
81+
* Create a render function that loads the hook from the given module path.
5982
*
60-
* @param options - Hook configuration (freeze is optional, defaults to false).
61-
* @returns renderHook result.
62-
* @example render({ inverted: false, keyboardLiftBehavior: "always" })
83+
* @param modulePath - Relative path to the hook module (e.g. `"../index.ios"` or `"../index"`).
84+
* @returns A render function bound to that module.
85+
* @example const render = createRender("../index.ios");
6386
*/
64-
export function render(
65-
options: Omit<Parameters<typeof useChatKeyboard>[1], "freeze" | "offset"> & {
66-
freeze?: boolean;
67-
offset?: number;
68-
},
69-
) {
70-
// eslint-disable-next-line @typescript-eslint/no-var-requires
71-
const mod = require("..") as { useChatKeyboard: typeof useChatKeyboard };
72-
73-
return renderHook(() => {
74-
const ref = useAnimatedRef<Reanimated.ScrollView>();
75-
76-
return mod.useChatKeyboard(ref, {
77-
...options,
78-
freeze: options.freeze ?? false,
79-
offset: options.offset ?? 0,
87+
export function createRender(modulePath: string) {
88+
return function render(options: RenderOptions) {
89+
// eslint-disable-next-line @typescript-eslint/no-var-requires
90+
const mod = require(modulePath) as {
91+
useChatKeyboard: typeof useChatKeyboard;
92+
};
93+
94+
return renderHook(() => {
95+
const ref = useAnimatedRef<Reanimated.ScrollView>();
96+
97+
return mod.useChatKeyboard(ref, {
98+
...options,
99+
freeze: options.freeze ?? false,
100+
offset: options.offset ?? 0,
101+
});
80102
});
81-
});
103+
};
82104
}

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/behaviors.android.spec.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { Platform } from "react-native";
2-
31
import {
42
type Handlers,
53
KEYBOARD,
4+
createRender,
65
mockLayout,
76
mockOffset,
87
mockScrollTo,
98
mockSize,
10-
render,
119
setupBeforeEach,
1210
} from "../__fixtures__/testUtils";
1311

12+
const render = createRender("../index.ts");
13+
1414
let handlers: Handlers = {
1515
onStart: jest.fn(),
1616
onMove: jest.fn(),
@@ -36,11 +36,6 @@ jest.mock("../../../hooks/useScrollState", () => ({
3636

3737
beforeEach(() => {
3838
setupBeforeEach();
39-
Object.defineProperty(Platform, "OS", { value: "android" });
40-
});
41-
42-
afterAll(() => {
43-
Object.defineProperty(Platform, "OS", { value: "ios" });
4439
});
4540

4641
describe("`useChatKeyboard` — Android behaviors", () => {
@@ -124,15 +119,6 @@ describe("`useChatKeyboard` — Android behaviors", () => {
124119
expect(mockScrollTo).not.toHaveBeenCalled();
125120
});
126121

127-
it("never inverted: should not scroll", () => {
128-
render({ inverted: true, keyboardLiftBehavior: "never" });
129-
130-
handlers.onStart({ height: KEYBOARD });
131-
handlers.onMove({ height: 200 });
132-
133-
expect(mockScrollTo).not.toHaveBeenCalled();
134-
});
135-
136122
it("whenAtEnd non-inverted: should scroll when at end", () => {
137123
mockOffset.value = 1180;
138124
render({ inverted: false, keyboardLiftBehavior: "whenAtEnd" });

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/freeze.android.spec.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { Platform } from "react-native";
2-
31
import {
42
type Handlers,
53
KEYBOARD,
4+
createRender,
65
mockLayout,
76
mockOffset,
87
mockScrollTo,
98
mockSize,
10-
render,
119
setupBeforeEach,
1210
} from "../__fixtures__/testUtils";
1311

12+
const render = createRender("../index.ts");
13+
1414
let handlers: Handlers = {
1515
onStart: jest.fn(),
1616
onMove: jest.fn(),
@@ -36,11 +36,6 @@ jest.mock("../../../hooks/useScrollState", () => ({
3636

3737
beforeEach(() => {
3838
setupBeforeEach();
39-
Object.defineProperty(Platform, "OS", { value: "android" });
40-
});
41-
42-
afterAll(() => {
43-
Object.defineProperty(Platform, "OS", { value: "ios" });
4439
});
4540

4641
describe("`useChatKeyboard` — Android freeze", () => {

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/helpers.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
jest.mock("react-native-reanimated", () => ({
2+
...require("react-native-reanimated/mock"),
3+
// eslint-disable-next-line @typescript-eslint/no-var-requires
4+
interpolate: require("../__fixtures__/testUtils").mockInterpolate,
5+
}));
6+
17
import {
28
clampedScrollTarget,
39
computeIOSContentOffset,
10+
getEffectiveHeight,
411
isScrollAtEnd,
512
shouldShiftContent,
613
} from "../helpers";
@@ -99,6 +106,36 @@ describe("`clampedScrollTarget` specification", () => {
99106
});
100107
});
101108

109+
describe("`getEffectiveHeight` specification", () => {
110+
it("should return height as-is when offset is 0", () => {
111+
expect(getEffectiveHeight(300, 300, 0)).toBe(300);
112+
expect(getEffectiveHeight(150, 300, 0)).toBe(150);
113+
});
114+
115+
it("should return height as-is when targetKeyboardHeight is 0", () => {
116+
expect(getEffectiveHeight(0, 0, 50)).toBe(0);
117+
});
118+
119+
it("should subtract offset proportionally at full keyboard height", () => {
120+
// interpolate(300, [0, 300], [0, 250]) = 250
121+
expect(getEffectiveHeight(300, 300, 50)).toBe(250);
122+
});
123+
124+
it("should interpolate proportionally at intermediate heights", () => {
125+
// interpolate(150, [0, 300], [0, 250]) = 125
126+
expect(getEffectiveHeight(150, 300, 50)).toBe(125);
127+
});
128+
129+
it("should return 0 when height is 0", () => {
130+
expect(getEffectiveHeight(0, 300, 50)).toBe(0);
131+
});
132+
133+
it("should clamp effective target to 0 when offset exceeds keyboard height", () => {
134+
// interpolate(300, [0, 300], [0, max(300 - 400, 0)]) = interpolate(300, [0, 300], [0, 0]) = 0
135+
expect(getEffectiveHeight(300, 300, 400)).toBe(0);
136+
});
137+
});
138+
102139
describe("`computeIOSContentOffset` specification", () => {
103140
describe("non-inverted", () => {
104141
it("should add keyboard height to relative scroll", () => {

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.android.spec.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { Platform } from "react-native";
2-
31
import {
42
type Handlers,
53
KEYBOARD,
4+
createRender,
65
mockLayout,
76
mockOffset,
87
mockScrollTo,
98
mockSize,
10-
render,
119
setupBeforeEach,
1210
} from "../__fixtures__/testUtils";
1311

12+
const render = createRender("../index.ts");
13+
1414
let handlers: Handlers = {
1515
onStart: jest.fn(),
1616
onMove: jest.fn(),
@@ -36,11 +36,6 @@ jest.mock("../../../hooks/useScrollState", () => ({
3636

3737
beforeEach(() => {
3838
setupBeforeEach();
39-
Object.defineProperty(Platform, "OS", { value: "android" });
40-
});
41-
42-
afterAll(() => {
43-
Object.defineProperty(Platform, "OS", { value: "ios" });
4439
});
4540

4641
describe("`useChatKeyboard` — Android non-inverted + always", () => {

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import {
22
type Handlers,
33
KEYBOARD,
4+
createRender,
45
mockLayout,
56
mockOffset,
67
mockScrollTo,
78
mockSize,
8-
render,
99
setupBeforeEach,
1010
} from "../__fixtures__/testUtils";
1111

12+
const render = createRender("../index.ios");
13+
1214
let handlers: Handlers = {
1315
onStart: jest.fn(),
1416
onMove: jest.fn(),

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/interactive.android.spec.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { Platform } from "react-native";
2-
31
import {
42
type Handlers,
53
KEYBOARD,
4+
createRender,
65
mockOffset,
76
mockScrollTo,
8-
render,
97
setupBeforeEach,
108
} from "../__fixtures__/testUtils";
119

10+
const render = createRender("../index.ts");
11+
1212
let handlers: Handlers = {
1313
onStart: jest.fn(),
1414
onMove: jest.fn(),
@@ -34,11 +34,6 @@ jest.mock("../../../hooks/useScrollState", () => ({
3434

3535
beforeEach(() => {
3636
setupBeforeEach();
37-
Object.defineProperty(Platform, "OS", { value: "android" });
38-
});
39-
40-
afterAll(() => {
41-
Object.defineProperty(Platform, "OS", { value: "ios" });
4237
});
4338

4439
describe("`useChatKeyboard` — Android post-interactive snap-back (inverted)", () => {

src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/offset.android.spec.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { Platform } from "react-native";
2-
31
import {
42
type Handlers,
53
KEYBOARD,
4+
createRender,
65
mockLayout,
76
mockOffset,
87
mockScrollTo,
98
mockSize,
10-
render,
119
setupBeforeEach,
1210
} from "../__fixtures__/testUtils";
1311

12+
const render = createRender("../index.ts");
13+
1414
let handlers: Handlers = {
1515
onStart: jest.fn(),
1616
onMove: jest.fn(),
@@ -36,11 +36,6 @@ jest.mock("../../../hooks/useScrollState", () => ({
3636

3737
beforeEach(() => {
3838
setupBeforeEach();
39-
Object.defineProperty(Platform, "OS", { value: "android" });
40-
});
41-
42-
afterAll(() => {
43-
Object.defineProperty(Platform, "OS", { value: "ios" });
4439
});
4540

4641
const OFFSET = 50;

0 commit comments

Comments
 (0)