1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { createSoftwareKeyboardStore } from "./softwareKeyboard";
4
5function fixture(options: { native?: boolean; overlay?: boolean; desktop?: boolean } = {}) {
6 const viewport = Object.assign(new EventTarget(), { width: 390, height: 844, scale: 1 });
7 const keyboard = Object.assign(new EventTarget(), {
8 boundingRect: { height: 0 },
9 overlaysContent: options.overlay ?? false,
10 });
11 const win = {
12 innerHeight: 844,
13 visualViewport: viewport,
14 navigator: options.native ? { virtualKeyboard: keyboard } : {},
15 };
16 const store = createSoftwareKeyboardStore(win as unknown as Window, options.desktop ?? false);
17 const resize = (height: number, width = viewport.width, scale = viewport.scale) => {
18 Object.assign(viewport, { height, width, scale });
19 viewport.dispatchEvent(new Event("resize"));
20 };
21 const geometry = (height: number) => {
22 keyboard.boundingRect.height = height;
23 keyboard.dispatchEvent(new Event("geometrychange"));
24 };
25 return { store, viewport, keyboard, win, resize, geometry };
26}
27
28describe("software keyboard detection", () => {
29 it("follows native keyboard geometry even when the viewport does not resize", () => {
30 const { store, geometry } = fixture({ native: true, overlay: true });
31 const observed: boolean[] = [];
32 const unsubscribe = store.subscribe(() => observed.push(store.getSnapshot()));
33 expect(store.getSnapshot()).toBe(false);
34
35 geometry(300);
36 expect(store.getSnapshot()).toBe(true);
37 geometry(0);
38 expect(store.getSnapshot()).toBe(false);
39 expect(observed).toEqual([true, false]);
40
41 unsubscribe();
42 geometry(300);
43 expect(observed).toEqual([true, false]);
44 });
45
46 // regression: #21763 composer Enter sent instead of inserting a newline on software keyboards
47 it("prefers overlay geometry over a misleading viewport height", () => {
48 const { store, resize, geometry } = fixture({ native: true, overlay: true });
49 const unsubscribe = store.subscribe(() => {});
50 resize(500);
51 expect(store.getSnapshot()).toBe(false);
52 geometry(300);
53 expect(store.getSnapshot()).toBe(true);
54 geometry(0);
55 expect(store.getSnapshot()).toBe(false);
56 unsubscribe();
57 });
58
59 it.each([false, true])("falls back to viewport changes with native API present: %s", (native) => {
60 const { store, resize, keyboard } = fixture({ native });
61 const unsubscribe = store.subscribe(() => {});
62
63 resize(500);
64 expect(store.getSnapshot()).toBe(true);
65 resize(550);
66 expect(store.getSnapshot()).toBe(true);
67 resize(844);
68 expect(store.getSnapshot()).toBe(false);
69 expect(keyboard.overlaysContent).toBe(false);
70 unsubscribe();
71 });
72
73 // regression: #21763 composer Enter sent instead of inserting a newline on software keyboards
74 it("detects a docked keyboard when subscribing after it has opened", () => {
75 const { store, resize } = fixture();
76 resize(500);
77 const unsubscribe = store.subscribe(() => {});
78 expect(store.getSnapshot()).toBe(true);
79 resize(844);
80 expect(store.getSnapshot()).toBe(false);
81 unsubscribe();
82 });
83
84 it("ignores small height changes such as the browser toolbar", () => {
85 const { store, resize } = fixture();
86 const unsubscribe = store.subscribe(() => {});
87 resize(750);
88 expect(store.getSnapshot()).toBe(false);
89 resize(700);
90 expect(store.getSnapshot()).toBe(false);
91 unsubscribe();
92 });
93
94 it("ignores pinch zoom and recognizes keyboard changes at the new scale", () => {
95 const { store, resize } = fixture();
96 const unsubscribe = store.subscribe(() => {});
97 resize(422, 195, 2);
98 expect(store.getSnapshot()).toBe(false);
99 resize(300);
100 expect(store.getSnapshot()).toBe(true);
101 resize(422);
102 expect(store.getSnapshot()).toBe(false);
103 unsubscribe();
104 });
105
106 it("does not mistake rotation for an open keyboard", () => {
107 const { store, resize, win } = fixture();
108 const unsubscribe = store.subscribe(() => {});
109 win.innerHeight = 390;
110 resize(390, 844);
111 expect(store.getSnapshot()).toBe(false);
112 unsubscribe();
113 });
114
115 it("recognizes keyboard closure despite viewport rounding at high zoom", () => {
116 const { store, resize } = fixture();
117 resize(211, 98, 4);
118 const unsubscribe = store.subscribe(() => {});
119 resize(150);
120 expect(store.getSnapshot()).toBe(true);
121 resize(210.6);
122 expect(store.getSnapshot()).toBe(false);
123 unsubscribe();
124 });
125
126 it("bypasses both native geometry and viewport detection on desktop", () => {
127 const { store, geometry, resize } = fixture({ native: true, overlay: true, desktop: true });
128 const listener = vi.fn();
129 const unsubscribe = store.subscribe(listener);
130 geometry(300);
131 resize(500);
132 expect(store.getSnapshot()).toBe(false);
133 expect(listener).not.toHaveBeenCalled();
134 unsubscribe();
135 });
136});