@seed-design/lynx-react 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/lib/components/Field/Field.d.ts +47 -0
  2. package/lib/components/Field/Field.d.ts.map +1 -0
  3. package/lib/components/Field/Field.jsx +121 -0
  4. package/lib/components/Field/Field.namespace.d.ts +2 -0
  5. package/lib/components/Field/Field.namespace.d.ts.map +1 -0
  6. package/lib/components/Field/Field.namespace.js +16 -0
  7. package/lib/components/Field/context.d.ts +19 -0
  8. package/lib/components/Field/context.d.ts.map +1 -0
  9. package/lib/components/Field/context.js +10 -0
  10. package/lib/components/Field/index.d.ts +3 -0
  11. package/lib/components/Field/index.d.ts.map +1 -0
  12. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts +42 -0
  13. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts.map +1 -0
  14. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.jsx +140 -0
  15. package/lib/components/KeyboardAvoidingScrollView/context.d.ts +17 -0
  16. package/lib/components/KeyboardAvoidingScrollView/context.d.ts.map +1 -0
  17. package/lib/components/KeyboardAvoidingScrollView/context.js +12 -0
  18. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts +50 -0
  19. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts.map +1 -0
  20. package/lib/components/KeyboardAvoidingScrollView/engine.js +259 -0
  21. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts +50 -0
  22. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts.map +1 -0
  23. package/lib/components/KeyboardAvoidingScrollView/geometry.js +90 -0
  24. package/lib/components/KeyboardAvoidingScrollView/index.d.ts +2 -0
  25. package/lib/components/KeyboardAvoidingScrollView/index.d.ts.map +1 -0
  26. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts +11 -0
  27. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts.map +1 -0
  28. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.js +43 -0
  29. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts +34 -0
  30. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts.map +1 -0
  31. package/lib/components/KeyboardAvoidingScrollView/native-driver.js +133 -0
  32. package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -1
  33. package/lib/components/ProgressCircle/ProgressCircle.jsx +36 -5
  34. package/lib/components/Stack/Stack.d.ts.map +1 -1
  35. package/lib/components/Stack/Stack.jsx +10 -1
  36. package/lib/components/TagGroup/TagGroup.d.ts +2 -1
  37. package/lib/components/TagGroup/TagGroup.d.ts.map +1 -1
  38. package/lib/components/TagGroup/TagGroup.jsx +2 -1
  39. package/lib/components/TextField/TextField.d.ts +131 -0
  40. package/lib/components/TextField/TextField.d.ts.map +1 -0
  41. package/lib/components/TextField/TextField.jsx +434 -0
  42. package/lib/components/TextField/TextField.namespace.d.ts +2 -0
  43. package/lib/components/TextField/TextField.namespace.d.ts.map +1 -0
  44. package/lib/components/TextField/TextField.namespace.js +14 -0
  45. package/lib/components/TextField/context.d.ts +22 -0
  46. package/lib/components/TextField/context.d.ts.map +1 -0
  47. package/lib/components/TextField/context.js +7 -0
  48. package/lib/components/TextField/index.d.ts +4 -0
  49. package/lib/components/TextField/index.d.ts.map +1 -0
  50. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts +24 -0
  51. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -0
  52. package/lib/components/TextField/useTextFieldWithGraphemes.js +47 -0
  53. package/lib/components/index.d.ts +3 -0
  54. package/lib/components/index.d.ts.map +1 -1
  55. package/lib/index.js +7 -1
  56. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_grapheme_data.js +8 -0
  57. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js +14 -0
  58. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js +68 -0
  59. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js +175 -0
  60. package/package.json +7 -5
  61. package/src/components/Field/Field.namespace.ts +19 -0
  62. package/src/components/Field/Field.test.tsx +81 -0
  63. package/src/components/Field/Field.tsx +252 -0
  64. package/src/components/Field/context.ts +26 -0
  65. package/src/components/Field/index.ts +21 -0
  66. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.test.tsx +267 -0
  67. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +299 -0
  68. package/src/components/KeyboardAvoidingScrollView/context.ts +24 -0
  69. package/src/components/KeyboardAvoidingScrollView/engine.test.ts +451 -0
  70. package/src/components/KeyboardAvoidingScrollView/engine.ts +417 -0
  71. package/src/components/KeyboardAvoidingScrollView/geometry.test.ts +315 -0
  72. package/src/components/KeyboardAvoidingScrollView/geometry.ts +194 -0
  73. package/src/components/KeyboardAvoidingScrollView/index.ts +5 -0
  74. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.test.ts +122 -0
  75. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.ts +75 -0
  76. package/src/components/KeyboardAvoidingScrollView/native-driver.test.ts +223 -0
  77. package/src/components/KeyboardAvoidingScrollView/native-driver.ts +248 -0
  78. package/src/components/ProgressCircle/ProgressCircle.test.tsx +58 -0
  79. package/src/components/ProgressCircle/ProgressCircle.tsx +42 -5
  80. package/src/components/Stack/Stack.test.tsx +67 -0
  81. package/src/components/Stack/Stack.tsx +15 -1
  82. package/src/components/TagGroup/TagGroup.test.tsx +73 -0
  83. package/src/components/TagGroup/TagGroup.tsx +4 -2
  84. package/src/components/TextField/TextField.namespace.ts +16 -0
  85. package/src/components/TextField/TextField.test.tsx +1052 -0
  86. package/src/components/TextField/TextField.tsx +954 -0
  87. package/src/components/TextField/context.ts +33 -0
  88. package/src/components/TextField/index.ts +23 -0
  89. package/src/components/TextField/useTextFieldWithGraphemes.test.tsx +80 -0
  90. package/src/components/TextField/useTextFieldWithGraphemes.ts +78 -0
  91. package/src/components/index.ts +3 -0
@@ -0,0 +1,451 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ createKeyboardAvoidingEngine,
5
+ type KeyboardAvoidanceRegistration,
6
+ type KeyboardAvoidingNativeDriver,
7
+ type KeyboardAvoidingScheduler,
8
+ } from "./engine";
9
+ import type { VerticalRect } from "./geometry";
10
+ import type { ScrollMetrics } from "./native-driver";
11
+
12
+ type TestNode = "anchor" | "control" | "field" | "native" | "scroll" | "spacer";
13
+ type ScheduledCallback = Parameters<KeyboardAvoidingScheduler["scheduleFrame"]>[0];
14
+
15
+ interface ScheduledTask {
16
+ callback: ScheduledCallback;
17
+ cancelled: boolean;
18
+ }
19
+
20
+ function createManualScheduler() {
21
+ const frames: ScheduledTask[] = [];
22
+ const timers: ScheduledTask[] = [];
23
+
24
+ const schedule = (queue: ScheduledTask[], callback: ScheduledCallback) => {
25
+ const task = { callback, cancelled: false };
26
+ queue.push(task);
27
+ return () => {
28
+ task.cancelled = true;
29
+ };
30
+ };
31
+
32
+ const scheduler = {
33
+ scheduleFrame: vi.fn((callback: ScheduledCallback) => schedule(frames, callback)),
34
+ scheduleTimer: vi.fn((callback: ScheduledCallback) => schedule(timers, callback)),
35
+ } satisfies KeyboardAvoidingScheduler;
36
+
37
+ const takeNext = (queue: ScheduledTask[]): ScheduledTask | null => {
38
+ while (queue.length > 0) {
39
+ const task = queue.shift();
40
+ if (task && !task.cancelled) return task;
41
+ }
42
+ return null;
43
+ };
44
+
45
+ return {
46
+ scheduler,
47
+ async flushFrame() {
48
+ await takeNext(frames)?.callback();
49
+ },
50
+ startFrame() {
51
+ const task = takeNext(frames);
52
+ return Promise.resolve(task?.callback());
53
+ },
54
+ async flushTimer() {
55
+ await takeNext(timers)?.callback();
56
+ },
57
+ };
58
+ }
59
+
60
+ function deferred<T>() {
61
+ let resolvePromise: (value: T) => void = () => {};
62
+ const promise = new Promise<T>((resolve) => {
63
+ resolvePromise = resolve;
64
+ });
65
+
66
+ return { promise, resolve: resolvePromise };
67
+ }
68
+
69
+ function nodeRef(current: TestNode) {
70
+ return { current };
71
+ }
72
+
73
+ function createRegistration(
74
+ owner: object = {},
75
+ options: Partial<KeyboardAvoidanceRegistration<TestNode>> = {},
76
+ ): KeyboardAvoidanceRegistration<TestNode> {
77
+ return {
78
+ owner,
79
+ fieldRef: nodeRef("field"),
80
+ controlRef: nodeRef("control"),
81
+ nativeRef: nodeRef("native"),
82
+ anchorRef: nodeRef("anchor"),
83
+ ...options,
84
+ };
85
+ }
86
+
87
+ function createHarness() {
88
+ const rects: Record<TestNode, VerticalRect | null> = {
89
+ scroll: { top: 0, bottom: 800 },
90
+ spacer: null,
91
+ field: { top: 400, bottom: 520 },
92
+ control: { top: 420, bottom: 480 },
93
+ native: { top: 430, bottom: 470 },
94
+ anchor: { top: 440, bottom: 460 },
95
+ };
96
+ let metrics: ScrollMetrics = { offsetY: 100, maxOffsetY: 1_000 };
97
+ let keyboardOcclusionTop = 500;
98
+ const manualScheduler = createManualScheduler();
99
+ const driver = {
100
+ measure: vi.fn(async (node: TestNode) => rects[node]),
101
+ resolveKeyboardOcclusion: vi.fn(async (state) =>
102
+ state.visible ? { visible: true, topInScreenPx: keyboardOcclusionTop } : null,
103
+ ),
104
+ setSpacerHeight: vi.fn(),
105
+ waitForLayout: vi.fn(async () => {}),
106
+ getScrollMetrics: vi.fn(async (): Promise<ScrollMetrics | null> => metrics),
107
+ scrollTo: vi.fn(),
108
+ } satisfies KeyboardAvoidingNativeDriver<TestNode>;
109
+ const engine = createKeyboardAvoidingEngine<TestNode>({
110
+ driver,
111
+ scheduler: manualScheduler.scheduler,
112
+ getScrollNode: () => "scroll",
113
+ getSpacerNode: () => "spacer",
114
+ getKeyboardGap: () => 24,
115
+ getToolbarHeight: () => 0,
116
+ getSmooth: () => true,
117
+ });
118
+
119
+ return {
120
+ driver,
121
+ engine,
122
+ rects,
123
+ scheduler: manualScheduler,
124
+ setMetrics(next: ScrollMetrics) {
125
+ metrics = next;
126
+ },
127
+ setKeyboardOcclusionTop(next: number) {
128
+ keyboardOcclusionTop = next;
129
+ },
130
+ };
131
+ }
132
+
133
+ async function openKeyboard(
134
+ harness: ReturnType<typeof createHarness>,
135
+ registration = createRegistration(),
136
+ ) {
137
+ harness.engine.focus(registration);
138
+ harness.engine.keyboardChanged({ visible: true, height: 300 });
139
+ await harness.scheduler.flushFrame();
140
+ return registration;
141
+ }
142
+
143
+ describe("createKeyboardAvoidingEngine", () => {
144
+ it("coalesces invalidations into one frame and applies one native transaction", async () => {
145
+ const harness = createHarness();
146
+ const registration = createRegistration();
147
+
148
+ harness.engine.focus(registration);
149
+ harness.engine.keyboardChanged({ visible: true, height: 300 });
150
+ harness.engine.layoutChanged(registration.owner);
151
+ harness.engine.viewportChanged();
152
+
153
+ expect(harness.scheduler.scheduler.scheduleFrame).toHaveBeenCalledTimes(1);
154
+
155
+ await harness.scheduler.flushFrame();
156
+
157
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledOnce();
158
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledWith("spacer", 324);
159
+ expect(harness.driver.scrollTo).toHaveBeenCalledWith("scroll", 144, true);
160
+ });
161
+
162
+ it("does not cross the bridge while closed with no applied spacer", async () => {
163
+ const harness = createHarness();
164
+
165
+ harness.engine.focus(createRegistration());
166
+ await harness.scheduler.flushFrame();
167
+
168
+ expect(harness.driver.measure).not.toHaveBeenCalled();
169
+ expect(harness.driver.getScrollMetrics).not.toHaveBeenCalled();
170
+ expect(harness.driver.setSpacerHeight).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it("updates the spacer but skips scrollTo when the target is already visible", async () => {
174
+ const harness = createHarness();
175
+ harness.rects.field = { top: 240, bottom: 360 };
176
+
177
+ await openKeyboard(harness);
178
+
179
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledWith("spacer", 324);
180
+ expect(harness.driver.scrollTo).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it("retries a synchronous spacer mutation failure on the next invalidation", async () => {
184
+ const harness = createHarness();
185
+ harness.driver.setSpacerHeight.mockImplementationOnce(() => {
186
+ throw new Error("detached");
187
+ });
188
+
189
+ await openKeyboard(harness);
190
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledTimes(1);
191
+
192
+ harness.engine.viewportChanged();
193
+ await harness.scheduler.flushFrame();
194
+
195
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledTimes(2);
196
+ expect(harness.driver.setSpacerHeight).toHaveBeenLastCalledWith("spacer", 324);
197
+ });
198
+
199
+ it("discards an in-flight measurement after focus changes", async () => {
200
+ const harness = createHarness();
201
+ const pendingViewport = deferred<VerticalRect | null>();
202
+ harness.driver.measure.mockImplementationOnce(() => pendingViewport.promise);
203
+ harness.engine.focus(createRegistration({ name: "first" }));
204
+ harness.engine.keyboardChanged({ visible: true, height: 300 });
205
+
206
+ const staleTransaction = harness.scheduler.startFrame();
207
+ harness.engine.focus(createRegistration({ name: "second" }));
208
+ pendingViewport.resolve({ top: 0, bottom: 800 });
209
+ await staleTransaction;
210
+
211
+ expect(harness.driver.setSpacerHeight).not.toHaveBeenCalled();
212
+
213
+ await harness.scheduler.flushFrame();
214
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledWith("spacer", 324);
215
+ });
216
+
217
+ it("pauses pending work during a user scroll and reevaluates after scrollend", async () => {
218
+ const harness = createHarness();
219
+ harness.engine.focus(createRegistration());
220
+ harness.engine.keyboardChanged({ visible: true, height: 300 });
221
+
222
+ harness.engine.userScrollStarted();
223
+ await harness.scheduler.flushFrame();
224
+
225
+ expect(harness.driver.measure).not.toHaveBeenCalled();
226
+
227
+ harness.engine.userScrollEnded();
228
+ await harness.scheduler.flushFrame();
229
+
230
+ expect(harness.driver.measure).toHaveBeenCalled();
231
+ expect(harness.driver.scrollTo).toHaveBeenCalledOnce();
232
+ });
233
+
234
+ it("removes the spacer and clamps an out-of-range offset when the keyboard closes", async () => {
235
+ const harness = createHarness();
236
+ await openKeyboard(harness);
237
+ harness.driver.setSpacerHeight.mockClear();
238
+ harness.driver.scrollTo.mockClear();
239
+ harness.setMetrics({ offsetY: 500, maxOffsetY: 200 });
240
+
241
+ harness.engine.keyboardChanged({ visible: false, height: 0 });
242
+ await harness.scheduler.flushFrame();
243
+
244
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledWith("spacer", 0);
245
+ expect(harness.driver.scrollTo).toHaveBeenCalledWith("scroll", 200, true);
246
+ });
247
+
248
+ it("retries close clamping when native metrics are temporarily unavailable", async () => {
249
+ const harness = createHarness();
250
+ await openKeyboard(harness);
251
+ harness.driver.getScrollMetrics.mockClear();
252
+ harness.driver.getScrollMetrics.mockResolvedValueOnce(null);
253
+ harness.setMetrics({ offsetY: 500, maxOffsetY: 200 });
254
+
255
+ harness.engine.keyboardChanged({ visible: false, height: 0 });
256
+ await harness.scheduler.flushFrame();
257
+
258
+ expect(harness.driver.scrollTo).not.toHaveBeenCalledWith("scroll", 200, true);
259
+
260
+ harness.engine.viewportChanged();
261
+ await harness.scheduler.flushFrame();
262
+
263
+ expect(harness.driver.getScrollMetrics).toHaveBeenCalledTimes(2);
264
+ expect(harness.driver.scrollTo).toHaveBeenLastCalledWith("scroll", 200, true);
265
+ });
266
+
267
+ it("clears the previous target when a disabled target receives focus", async () => {
268
+ const harness = createHarness();
269
+ await openKeyboard(harness, createRegistration({ name: "enabled" }));
270
+ harness.driver.setSpacerHeight.mockClear();
271
+
272
+ harness.engine.focus(
273
+ createRegistration(
274
+ { name: "disabled" },
275
+ {
276
+ enabled: false,
277
+ },
278
+ ),
279
+ );
280
+ await harness.scheduler.flushFrame();
281
+
282
+ expect(harness.driver.setSpacerHeight).toHaveBeenCalledWith("spacer", 0);
283
+ });
284
+
285
+ it("keeps the spacer during a blur-to-focus handoff", async () => {
286
+ const harness = createHarness();
287
+ const first = await openKeyboard(harness, createRegistration({ name: "first" }));
288
+ harness.driver.setSpacerHeight.mockClear();
289
+
290
+ harness.engine.blur(first.owner);
291
+ harness.engine.focus(createRegistration({ name: "second" }));
292
+ await harness.scheduler.flushTimer();
293
+ await harness.scheduler.flushFrame();
294
+
295
+ expect(harness.driver.setSpacerHeight).not.toHaveBeenCalledWith("spacer", 0);
296
+ });
297
+
298
+ it("keeps a Field downgrade stable for the current keyboard session", async () => {
299
+ const harness = createHarness();
300
+ const registration = createRegistration();
301
+ harness.rects.field = { top: 0, bottom: 900 };
302
+ harness.rects.control = { top: -2, bottom: 78 };
303
+ await openKeyboard(harness, registration);
304
+ harness.driver.measure.mockClear();
305
+ harness.driver.scrollTo.mockClear();
306
+
307
+ harness.rects.field = { top: 400, bottom: 500 };
308
+ harness.rects.control = { top: 430, bottom: 510 };
309
+ harness.engine.layoutChanged(registration.owner);
310
+ await harness.scheduler.flushFrame();
311
+
312
+ expect(harness.driver.measure).not.toHaveBeenCalledWith("field");
313
+ expect(harness.driver.scrollTo).toHaveBeenCalledWith("scroll", 134, true);
314
+ });
315
+
316
+ it("keeps the Field footer visible and remeasures it while the Field body grows", async () => {
317
+ const harness = createHarness();
318
+ const registration = createRegistration();
319
+ harness.rects.field = { top: 0, bottom: 500 };
320
+ harness.rects.control = { top: 420, bottom: 470 };
321
+
322
+ await openKeyboard(harness, registration);
323
+
324
+ // Field 전체는 safe area보다 크지만 control부터 footer까지는 들어오므로
325
+ // Field bottom을 기준으로 keyboardGap을 확보한다.
326
+ expect(harness.driver.scrollTo).toHaveBeenLastCalledWith("scroll", 124, true);
327
+
328
+ harness.driver.measure.mockClear();
329
+ harness.driver.scrollTo.mockClear();
330
+ harness.setMetrics({ offsetY: 124, maxOffsetY: 1_000 });
331
+ harness.rects.field = { top: -24, bottom: 516 };
332
+ harness.rects.control = { top: 396, bottom: 486 };
333
+
334
+ harness.engine.layoutChanged(registration.owner);
335
+ await harness.scheduler.flushFrame();
336
+
337
+ expect(harness.driver.measure).toHaveBeenCalledWith("field");
338
+ expect(harness.driver.scrollTo).toHaveBeenCalledWith("scroll", 164, true);
339
+ });
340
+
341
+ it("follows the Field bottom while every autoresize target is taller than the safe area", async () => {
342
+ const harness = createHarness();
343
+ const registration = createRegistration({}, { anchorRef: undefined });
344
+ harness.rects.scroll = { top: 124, bottom: 840 };
345
+ harness.setKeyboardOcclusionTop(515);
346
+ harness.rects.field = { top: -205.67, bottom: 583.33 };
347
+ harness.rects.control = { top: -175.67, bottom: 556.33 };
348
+ harness.rects.native = { top: -175.67, bottom: 556.33 };
349
+
350
+ await openKeyboard(harness, registration);
351
+
352
+ const firstScroll = harness.driver.scrollTo.mock.calls.at(-1);
353
+ expect(firstScroll?.[0]).toBe("scroll");
354
+ expect(firstScroll?.[1]).toBeCloseTo(192.33);
355
+ expect(firstScroll?.[2]).toBe(true);
356
+
357
+ harness.driver.measure.mockClear();
358
+ harness.driver.scrollTo.mockClear();
359
+ harness.setMetrics({ offsetY: 192.33, maxOffsetY: 1_000 });
360
+ harness.rects.field = { top: -258, bottom: 531 };
361
+ harness.rects.control = { top: -228, bottom: 504 };
362
+ harness.rects.native = { top: -228, bottom: 504 };
363
+
364
+ harness.engine.layoutChanged(registration.owner);
365
+ await harness.scheduler.flushFrame();
366
+
367
+ expect(harness.driver.measure).toHaveBeenCalledWith("field");
368
+ const growthScroll = harness.driver.scrollTo.mock.calls.at(-1);
369
+ expect(growthScroll?.[0]).toBe("scroll");
370
+ expect(growthScroll?.[1]).toBeCloseTo(232.33);
371
+ expect(growthScroll?.[2]).toBe(true);
372
+ });
373
+
374
+ it("keeps measuring the Field when its body is oversized but the control still fits", async () => {
375
+ const harness = createHarness();
376
+ const registration = createRegistration({}, { anchorRef: undefined });
377
+ harness.rects.scroll = { top: 124, bottom: 840 };
378
+ harness.setKeyboardOcclusionTop(515);
379
+ harness.rects.field = { top: 100, bottom: 507 };
380
+ harness.rects.control = { top: 130, bottom: 480 };
381
+ harness.rects.native = { top: 130, bottom: 480 };
382
+
383
+ await openKeyboard(harness, registration);
384
+
385
+ expect(harness.driver.scrollTo).toHaveBeenLastCalledWith("scroll", 116, true);
386
+
387
+ harness.driver.measure.mockClear();
388
+ harness.driver.scrollTo.mockClear();
389
+ harness.setMetrics({ offsetY: 116, maxOffsetY: 1_000 });
390
+ harness.rects.field = { top: 84, bottom: 511 };
391
+ harness.rects.control = { top: 114, bottom: 484 };
392
+ harness.rects.native = { top: 114, bottom: 484 };
393
+
394
+ harness.engine.layoutChanged(registration.owner);
395
+ await harness.scheduler.flushFrame();
396
+
397
+ expect(harness.driver.measure).toHaveBeenCalledWith("field");
398
+ expect(harness.driver.scrollTo).toHaveBeenCalledWith("scroll", 136, true);
399
+ });
400
+
401
+ it("does not follow upward when an oversized Field shrinks back into the safe area", async () => {
402
+ const harness = createHarness();
403
+ const registration = createRegistration({}, { anchorRef: undefined });
404
+ harness.rects.field = { top: 0, bottom: 600 };
405
+ harness.rects.control = { top: 0, bottom: 600 };
406
+ harness.rects.native = { top: 0, bottom: 600 };
407
+ await openKeyboard(harness, registration);
408
+ harness.driver.getScrollMetrics.mockClear();
409
+ harness.driver.scrollTo.mockClear();
410
+
411
+ harness.rects.field = { top: 50, bottom: 350 };
412
+ harness.rects.control = { top: 80, bottom: 320 };
413
+ harness.rects.native = { top: 80, bottom: 320 };
414
+ harness.engine.layoutChanged(registration.owner);
415
+ await harness.scheduler.flushFrame();
416
+
417
+ expect(harness.driver.getScrollMetrics).not.toHaveBeenCalled();
418
+ expect(harness.driver.scrollTo).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it("remeasures Field after every candidate was temporarily oversized", async () => {
422
+ const harness = createHarness();
423
+ const registration = createRegistration();
424
+ harness.rects.field = { top: 0, bottom: 600 };
425
+ harness.rects.control = { top: 0, bottom: 600 };
426
+ harness.rects.native = { top: 0, bottom: 600 };
427
+ harness.rects.anchor = { top: 0, bottom: 600 };
428
+ await openKeyboard(harness, registration);
429
+ harness.driver.measure.mockClear();
430
+
431
+ harness.rects.field = { top: 400, bottom: 500 };
432
+ harness.engine.layoutChanged(registration.owner);
433
+ await harness.scheduler.flushFrame();
434
+
435
+ expect(harness.driver.measure).toHaveBeenCalledWith("field");
436
+ });
437
+
438
+ it("does not follow a layout-only target height decrease", async () => {
439
+ const harness = createHarness();
440
+ const registration = await openKeyboard(harness);
441
+ harness.driver.scrollTo.mockClear();
442
+ harness.driver.getScrollMetrics.mockClear();
443
+
444
+ harness.rects.field = { top: 450, bottom: 500 };
445
+ harness.engine.layoutChanged(registration.owner);
446
+ await harness.scheduler.flushFrame();
447
+
448
+ expect(harness.driver.getScrollMetrics).not.toHaveBeenCalled();
449
+ expect(harness.driver.scrollTo).not.toHaveBeenCalled();
450
+ });
451
+ });