@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,315 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ calculateSafeArea,
5
+ calculateSignedScrollDelta,
6
+ clampScrollOffset,
7
+ createFieldBodyRect,
8
+ hasMeaningfulGeometryChange,
9
+ selectLargestFittingTarget,
10
+ type VerticalRect,
11
+ } from "./geometry";
12
+
13
+ function rect(top: number, height: number): VerticalRect {
14
+ return { top, bottom: top + height };
15
+ }
16
+
17
+ describe("calculateSafeArea", () => {
18
+ it("calculates the safe area and spacer in screen coordinates", () => {
19
+ expect(
20
+ calculateSafeArea({
21
+ viewport: { top: 120, bottom: 720 },
22
+ keyboardOcclusionTop: 640,
23
+ toolbarHeight: 48,
24
+ keyboardGap: 24,
25
+ }),
26
+ ).toEqual({
27
+ safeTop: 120,
28
+ safeBottom: 568,
29
+ spacerHeight: 152,
30
+ });
31
+ });
32
+
33
+ it("does not add a spacer when the occlusion is below the viewport", () => {
34
+ expect(
35
+ calculateSafeArea({
36
+ viewport: { top: 120, bottom: 720 },
37
+ keyboardOcclusionTop: 900,
38
+ toolbarHeight: 48,
39
+ keyboardGap: 24,
40
+ }),
41
+ ).toEqual({
42
+ safeTop: 120,
43
+ safeBottom: 720,
44
+ spacerHeight: 0,
45
+ });
46
+ });
47
+
48
+ it("normalizes negative toolbar height and keyboard gap to zero", () => {
49
+ expect(
50
+ calculateSafeArea({
51
+ viewport: { top: 120, bottom: 720 },
52
+ keyboardOcclusionTop: 640,
53
+ toolbarHeight: -48,
54
+ keyboardGap: -24,
55
+ }),
56
+ ).toEqual({
57
+ safeTop: 120,
58
+ safeBottom: 640,
59
+ spacerHeight: 80,
60
+ });
61
+ });
62
+
63
+ it("normalizes non-finite toolbar height and keyboard gap to zero", () => {
64
+ expect(
65
+ calculateSafeArea({
66
+ viewport: { top: 120, bottom: 720 },
67
+ keyboardOcclusionTop: 640,
68
+ toolbarHeight: Number.POSITIVE_INFINITY,
69
+ keyboardGap: Number.NaN,
70
+ }),
71
+ ).toEqual({
72
+ safeTop: 120,
73
+ safeBottom: 640,
74
+ spacerHeight: 80,
75
+ });
76
+ });
77
+
78
+ it("clamps the safe bottom to the viewport top when the viewport is fully occluded", () => {
79
+ expect(
80
+ calculateSafeArea({
81
+ viewport: { top: 120, bottom: 720 },
82
+ keyboardOcclusionTop: 100,
83
+ toolbarHeight: 48,
84
+ keyboardGap: 24,
85
+ }),
86
+ ).toEqual({
87
+ safeTop: 120,
88
+ safeBottom: 120,
89
+ spacerHeight: 600,
90
+ });
91
+ });
92
+
93
+ it("normalizes an invalid viewport bottom to its top", () => {
94
+ expect(
95
+ calculateSafeArea({
96
+ viewport: { top: 720, bottom: 120 },
97
+ keyboardOcclusionTop: 640,
98
+ toolbarHeight: 48,
99
+ keyboardGap: 24,
100
+ }),
101
+ ).toEqual({
102
+ safeTop: 720,
103
+ safeBottom: 720,
104
+ spacerHeight: 0,
105
+ });
106
+ });
107
+ });
108
+
109
+ describe("selectLargestFittingTarget", () => {
110
+ const safeArea = { safeTop: 100, safeBottom: 500 };
111
+
112
+ it("selects the Field when it fits", () => {
113
+ const field = rect(0, 350);
114
+
115
+ expect(
116
+ selectLargestFittingTarget(
117
+ {
118
+ field,
119
+ control: rect(0, 100),
120
+ native: rect(0, 80),
121
+ anchor: rect(0, 20),
122
+ },
123
+ safeArea,
124
+ ),
125
+ ).toEqual({ kind: "field", rect: field });
126
+ });
127
+
128
+ it.each([
129
+ {
130
+ name: "Field body",
131
+ candidates: {
132
+ field: rect(0, 402),
133
+ fieldBody: rect(100, 350),
134
+ control: rect(0, 200),
135
+ native: rect(0, 80),
136
+ anchor: rect(0, 20),
137
+ },
138
+ expectedKind: "fieldBody",
139
+ },
140
+ {
141
+ name: "control",
142
+ candidates: {
143
+ field: rect(0, 402),
144
+ control: rect(0, 200),
145
+ native: rect(0, 80),
146
+ anchor: rect(0, 20),
147
+ },
148
+ expectedKind: "control",
149
+ },
150
+ {
151
+ name: "native control",
152
+ candidates: {
153
+ field: rect(0, 402),
154
+ control: rect(0, 402),
155
+ native: rect(0, 80),
156
+ anchor: rect(0, 20),
157
+ },
158
+ expectedKind: "native",
159
+ },
160
+ {
161
+ name: "anchor",
162
+ candidates: {
163
+ field: rect(0, 402),
164
+ control: rect(0, 402),
165
+ native: rect(0, 402),
166
+ anchor: rect(0, 20),
167
+ },
168
+ expectedKind: "anchor",
169
+ },
170
+ ])("falls back to the $name when larger targets do not fit", ({ candidates, expectedKind }) => {
171
+ expect(selectLargestFittingTarget(candidates, safeArea)?.kind).toBe(expectedKind);
172
+ });
173
+
174
+ it("allows a target to exceed the safe height by the 1px epsilon", () => {
175
+ expect(
176
+ selectLargestFittingTarget(
177
+ {
178
+ field: rect(0, 401),
179
+ control: rect(0, 200),
180
+ },
181
+ safeArea,
182
+ )?.kind,
183
+ ).toBe("field");
184
+ });
185
+
186
+ it("returns null when every target is oversized", () => {
187
+ expect(
188
+ selectLargestFittingTarget(
189
+ {
190
+ field: rect(0, 404),
191
+ control: rect(0, 403),
192
+ native: rect(0, 402),
193
+ anchor: rect(0, 401.01),
194
+ },
195
+ safeArea,
196
+ ),
197
+ ).toBeNull();
198
+ });
199
+
200
+ it("falls back to the Field bottom when every semantic target is oversized", () => {
201
+ expect(
202
+ selectLargestFittingTarget(
203
+ {
204
+ field: rect(-206, 789),
205
+ fieldBody: rect(-176, 759),
206
+ control: rect(-176, 732),
207
+ native: rect(-176, 732),
208
+ },
209
+ { safeTop: 124, safeBottom: 491 },
210
+ ),
211
+ ).toEqual({
212
+ kind: "fieldBottom",
213
+ rect: { top: 583, bottom: 583 },
214
+ });
215
+ });
216
+
217
+ it("prioritizes the Field bottom over a fitting control when the Field body is oversized", () => {
218
+ expect(
219
+ selectLargestFittingTarget(
220
+ {
221
+ field: rect(100, 407),
222
+ fieldBody: rect(130, 377),
223
+ control: rect(130, 350),
224
+ native: rect(130, 350),
225
+ },
226
+ { safeTop: 124, safeBottom: 491 },
227
+ ),
228
+ ).toEqual({
229
+ kind: "fieldBottom",
230
+ rect: { top: 507, bottom: 507 },
231
+ });
232
+ });
233
+ });
234
+
235
+ describe("createFieldBodyRect", () => {
236
+ it("creates a target from the control top through the Field footer", () => {
237
+ expect(createFieldBodyRect({ top: 100, bottom: 500 }, { top: 180, bottom: 460 })).toEqual({
238
+ top: 180,
239
+ bottom: 500,
240
+ });
241
+ });
242
+
243
+ it("allows native measurement differences within the 1px containment epsilon", () => {
244
+ expect(createFieldBodyRect({ top: 100, bottom: 500 }, { top: 99, bottom: 501 })).toEqual({
245
+ top: 99,
246
+ bottom: 501,
247
+ });
248
+ });
249
+
250
+ it.each([
251
+ {
252
+ name: "control starts outside the Field",
253
+ field: { top: 100, bottom: 500 },
254
+ control: { top: 98.99, bottom: 460 },
255
+ },
256
+ {
257
+ name: "control ends outside the Field",
258
+ field: { top: 100, bottom: 500 },
259
+ control: { top: 180, bottom: 501.01 },
260
+ },
261
+ {
262
+ name: "Field has an invalid height",
263
+ field: { top: 500, bottom: 100 },
264
+ control: { top: 180, bottom: 460 },
265
+ },
266
+ ])("returns null when $name", ({ field, control }) => {
267
+ expect(createFieldBodyRect(field, control)).toBeNull();
268
+ });
269
+ });
270
+
271
+ describe("calculateSignedScrollDelta", () => {
272
+ const safeArea = { safeTop: 100, safeBottom: 500 };
273
+
274
+ it("returns a negative delta for a target above the safe area", () => {
275
+ expect(calculateSignedScrollDelta({ top: 70, bottom: 90 }, safeArea)).toBe(-30);
276
+ });
277
+
278
+ it("returns a positive delta for a target below the safe area", () => {
279
+ expect(calculateSignedScrollDelta({ top: 510, bottom: 530 }, safeArea)).toBe(30);
280
+ });
281
+
282
+ it("returns zero for a visible target", () => {
283
+ expect(calculateSignedScrollDelta({ top: 200, bottom: 300 }, safeArea)).toBe(0);
284
+ });
285
+
286
+ it.each([
287
+ { target: { top: 99, bottom: 300 }, edge: "top" },
288
+ { target: { top: 200, bottom: 501 }, edge: "bottom" },
289
+ ])("treats a target within 1px of the $edge edge as visible", ({ target }) => {
290
+ expect(calculateSignedScrollDelta(target, safeArea)).toBe(0);
291
+ });
292
+ });
293
+
294
+ describe("clampScrollOffset", () => {
295
+ it.each([
296
+ { offset: -20, maxOffset: 300, expected: 0 },
297
+ { offset: 120, maxOffset: 300, expected: 120 },
298
+ { offset: 420, maxOffset: 300, expected: 300 },
299
+ { offset: 20, maxOffset: -1, expected: 0 },
300
+ ])("clamps $offset to $expected for max offset $maxOffset", ({ offset, maxOffset, expected }) => {
301
+ expect(clampScrollOffset(offset, maxOffset)).toBe(expected);
302
+ });
303
+ });
304
+
305
+ describe("hasMeaningfulGeometryChange", () => {
306
+ it("ignores changes within the 1px epsilon", () => {
307
+ expect(hasMeaningfulGeometryChange(100, 101)).toBe(false);
308
+ expect(hasMeaningfulGeometryChange(100, 99)).toBe(false);
309
+ });
310
+
311
+ it("detects changes beyond the 1px epsilon", () => {
312
+ expect(hasMeaningfulGeometryChange(100, 101.01)).toBe(true);
313
+ expect(hasMeaningfulGeometryChange(100, 98.99)).toBe(true);
314
+ });
315
+ });
@@ -0,0 +1,194 @@
1
+ export const KEYBOARD_AVOIDANCE_EPSILON_PX = 1;
2
+
3
+ export interface VerticalRect {
4
+ top: number;
5
+ bottom: number;
6
+ }
7
+
8
+ export interface SafeArea {
9
+ safeTop: number;
10
+ safeBottom: number;
11
+ spacerHeight: number;
12
+ }
13
+
14
+ export interface CalculateSafeAreaOptions {
15
+ viewport: VerticalRect;
16
+ keyboardOcclusionTop: number;
17
+ toolbarHeight: number;
18
+ keyboardGap: number;
19
+ }
20
+
21
+ export type AvoidanceTargetKind =
22
+ | "field"
23
+ | "fieldBody"
24
+ | "control"
25
+ | "native"
26
+ | "anchor"
27
+ | "fieldBottom";
28
+
29
+ export interface AvoidanceTarget {
30
+ kind: AvoidanceTargetKind;
31
+ rect: VerticalRect;
32
+ }
33
+
34
+ export interface AvoidanceTargetCandidates {
35
+ field?: VerticalRect | null;
36
+ fieldBody?: VerticalRect | null;
37
+ control?: VerticalRect | null;
38
+ native?: VerticalRect | null;
39
+ anchor?: VerticalRect | null;
40
+ }
41
+
42
+ function normalizeNonNegativeFinite(value: number): number {
43
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
44
+ }
45
+
46
+ /**
47
+ * screen 좌표계에서 키보드와 툴바가 가리지 않는 세로 영역을 계산한다.
48
+ */
49
+ export function calculateSafeArea({
50
+ viewport,
51
+ keyboardOcclusionTop,
52
+ toolbarHeight,
53
+ keyboardGap,
54
+ }: CalculateSafeAreaOptions): SafeArea {
55
+ const normalizedToolbarHeight = normalizeNonNegativeFinite(toolbarHeight);
56
+ const normalizedKeyboardGap = normalizeNonNegativeFinite(keyboardGap);
57
+ const normalizedViewportBottom = Math.max(viewport.top, viewport.bottom);
58
+ const safeBottom = Math.min(
59
+ normalizedViewportBottom,
60
+ Math.max(viewport.top, keyboardOcclusionTop - normalizedToolbarHeight - normalizedKeyboardGap),
61
+ );
62
+
63
+ return {
64
+ safeTop: viewport.top,
65
+ safeBottom,
66
+ spacerHeight: normalizedViewportBottom - safeBottom,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Field의 header를 제외하고 control부터 footer까지 포함하는 영역을 만든다.
72
+ */
73
+ export function createFieldBodyRect(
74
+ field: VerticalRect | null,
75
+ control: VerticalRect | null,
76
+ epsilon = KEYBOARD_AVOIDANCE_EPSILON_PX,
77
+ ): VerticalRect | null {
78
+ if (field === null || control === null) return null;
79
+
80
+ const normalizedEpsilon = Math.max(0, epsilon);
81
+ const fieldHeight = field.bottom - field.top;
82
+ const controlHeight = control.bottom - control.top;
83
+ if (
84
+ fieldHeight < 0 ||
85
+ controlHeight < 0 ||
86
+ control.top < field.top - normalizedEpsilon ||
87
+ control.bottom > field.bottom + normalizedEpsilon
88
+ ) {
89
+ return null;
90
+ }
91
+
92
+ return { top: control.top, bottom: Math.max(field.bottom, control.bottom) };
93
+ }
94
+
95
+ /**
96
+ * 화면에 온전히 들어오는 가장 큰 의미 단위를 선택한다.
97
+ * 우선순위는 Field, Field body, Field bottom, control, native control, 정밀 anchor 순이다.
98
+ * Field body가 너무 크면 autoresize Field의 footer를 추적할 수 있도록
99
+ * control보다 먼저 Field body의 하단점을 사용한다.
100
+ */
101
+ export function selectLargestFittingTarget(
102
+ candidates: AvoidanceTargetCandidates,
103
+ safeArea: Pick<SafeArea, "safeTop" | "safeBottom">,
104
+ epsilon = KEYBOARD_AVOIDANCE_EPSILON_PX,
105
+ ): AvoidanceTarget | null {
106
+ const availableHeight = Math.max(0, safeArea.safeBottom - safeArea.safeTop);
107
+ const normalizedEpsilon = Math.max(0, epsilon);
108
+ const fieldCandidates: readonly (readonly [
109
+ AvoidanceTargetKind,
110
+ VerticalRect | null | undefined,
111
+ ])[] = [
112
+ ["field", candidates.field],
113
+ ["fieldBody", candidates.fieldBody],
114
+ ];
115
+ const fallbackCandidates: readonly (readonly [
116
+ AvoidanceTargetKind,
117
+ VerticalRect | null | undefined,
118
+ ])[] = [
119
+ ["control", candidates.control],
120
+ ["native", candidates.native],
121
+ ["anchor", candidates.anchor],
122
+ ];
123
+
124
+ for (const [kind, rect] of fieldCandidates) {
125
+ if (!rect) {
126
+ continue;
127
+ }
128
+
129
+ const targetHeight = rect.bottom - rect.top;
130
+ if (targetHeight >= 0 && targetHeight <= availableHeight + normalizedEpsilon) {
131
+ return { kind, rect };
132
+ }
133
+ }
134
+
135
+ const fieldBody = candidates.fieldBody;
136
+ const fieldBodyHeight = fieldBody ? fieldBody.bottom - fieldBody.top : -1;
137
+ if (
138
+ fieldBody &&
139
+ Number.isFinite(fieldBody.top) &&
140
+ Number.isFinite(fieldBody.bottom) &&
141
+ fieldBodyHeight > availableHeight + normalizedEpsilon
142
+ ) {
143
+ return {
144
+ kind: "fieldBottom",
145
+ rect: { top: fieldBody.bottom, bottom: fieldBody.bottom },
146
+ };
147
+ }
148
+
149
+ for (const [kind, rect] of fallbackCandidates) {
150
+ if (!rect) {
151
+ continue;
152
+ }
153
+
154
+ const targetHeight = rect.bottom - rect.top;
155
+ if (targetHeight >= 0 && targetHeight <= availableHeight + normalizedEpsilon) {
156
+ return { kind, rect };
157
+ }
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * 양수는 아래로, 음수는 위로 이동해야 하는 scroll offset 변화량이다.
165
+ */
166
+ export function calculateSignedScrollDelta(
167
+ target: VerticalRect,
168
+ safeArea: Pick<SafeArea, "safeTop" | "safeBottom">,
169
+ epsilon = KEYBOARD_AVOIDANCE_EPSILON_PX,
170
+ ): number {
171
+ const normalizedEpsilon = Math.max(0, epsilon);
172
+
173
+ if (target.top < safeArea.safeTop - normalizedEpsilon) {
174
+ return target.top - safeArea.safeTop;
175
+ }
176
+
177
+ if (target.bottom > safeArea.safeBottom + normalizedEpsilon) {
178
+ return target.bottom - safeArea.safeBottom;
179
+ }
180
+
181
+ return 0;
182
+ }
183
+
184
+ export function clampScrollOffset(offset: number, maxOffset: number): number {
185
+ return Math.min(Math.max(0, offset), Math.max(0, maxOffset));
186
+ }
187
+
188
+ export function hasMeaningfulGeometryChange(
189
+ previous: number,
190
+ next: number,
191
+ epsilon = KEYBOARD_AVOIDANCE_EPSILON_PX,
192
+ ): boolean {
193
+ return Math.abs(next - previous) > Math.max(0, epsilon);
194
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ KeyboardAvoidingScrollView,
3
+ type KeyboardAvoidingScrollBehavior,
4
+ type KeyboardAvoidingScrollViewProps,
5
+ } from "./KeyboardAvoidingScrollView";
@@ -0,0 +1,122 @@
1
+ import type { GlobalEventEmitter } from "@lynx-js/types";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { createKeyboardEventSource } from "./keyboard-event-source";
5
+
6
+ function createEmitter() {
7
+ let listener: ((...args: unknown[]) => void) | null = null;
8
+ const emitter = {
9
+ addListener: vi.fn((_eventName: string, nextListener: (...args: unknown[]) => void) => {
10
+ listener = nextListener;
11
+ }),
12
+ removeListener: vi.fn(),
13
+ } as unknown as GlobalEventEmitter;
14
+
15
+ return {
16
+ emitter,
17
+ emit(...args: unknown[]) {
18
+ listener?.(...args);
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("createKeyboardEventSource", () => {
24
+ it("shares one native subscription across subscribers", () => {
25
+ const { emitter, emit } = createEmitter();
26
+ const source = createKeyboardEventSource(() => emitter);
27
+ const first = vi.fn();
28
+ const second = vi.fn();
29
+
30
+ const unsubscribeFirst = source.subscribe(first);
31
+ const unsubscribeSecond = source.subscribe(second);
32
+
33
+ expect(emitter.addListener).toHaveBeenCalledTimes(1);
34
+ expect(first).toHaveBeenLastCalledWith({ visible: false, height: 0 });
35
+ expect(second).toHaveBeenLastCalledWith({ visible: false, height: 0 });
36
+
37
+ emit("on", 320);
38
+
39
+ expect(first).toHaveBeenLastCalledWith({ visible: true, height: 320 });
40
+ expect(second).toHaveBeenLastCalledWith({ visible: true, height: 320 });
41
+
42
+ unsubscribeFirst();
43
+ expect(emitter.removeListener).not.toHaveBeenCalled();
44
+
45
+ unsubscribeSecond();
46
+ expect(emitter.removeListener).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it("replays the latest state to a late subscriber", () => {
50
+ const { emitter, emit } = createEmitter();
51
+ const source = createKeyboardEventSource(() => emitter);
52
+ const first = vi.fn();
53
+ const second = vi.fn();
54
+
55
+ source.subscribe(first);
56
+ emit("on", 280);
57
+ source.subscribe(second);
58
+
59
+ expect(second).toHaveBeenCalledWith({ visible: true, height: 280 });
60
+ });
61
+
62
+ it("tracks duplicate listener subscriptions independently", () => {
63
+ const { emitter, emit } = createEmitter();
64
+ const source = createKeyboardEventSource(() => emitter);
65
+ const listener = vi.fn();
66
+
67
+ const unsubscribeFirst = source.subscribe(listener);
68
+ const unsubscribeSecond = source.subscribe(listener);
69
+ listener.mockClear();
70
+
71
+ unsubscribeFirst();
72
+ emit("on", 280);
73
+
74
+ expect(listener).toHaveBeenCalledTimes(1);
75
+ expect(emitter.removeListener).not.toHaveBeenCalled();
76
+
77
+ unsubscribeSecond();
78
+ expect(emitter.removeListener).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("keeps tracking keyboard state while there are no component subscribers", () => {
82
+ const { emitter, emit } = createEmitter();
83
+ const source = createKeyboardEventSource(() => emitter);
84
+ const first = vi.fn();
85
+ const second = vi.fn();
86
+
87
+ const unsubscribe = source.subscribe(first);
88
+ unsubscribe();
89
+ emit("on", 280);
90
+ source.subscribe(second);
91
+
92
+ expect(emitter.addListener).toHaveBeenCalledTimes(1);
93
+ expect(second).toHaveBeenCalledWith({ visible: true, height: 280 });
94
+ });
95
+
96
+ it("fails softly when the native emitter is unavailable", () => {
97
+ const source = createKeyboardEventSource(() => {
98
+ throw new Error("unsupported");
99
+ });
100
+ const listener = vi.fn();
101
+
102
+ const unsubscribe = source.subscribe(listener);
103
+
104
+ expect(listener).toHaveBeenCalledWith({ visible: false, height: 0 });
105
+ expect(unsubscribe).not.toThrow();
106
+ });
107
+
108
+ it("normalizes invalid or off payloads", () => {
109
+ const { emitter, emit } = createEmitter();
110
+ const source = createKeyboardEventSource(() => emitter);
111
+ const listener = vi.fn();
112
+
113
+ source.subscribe(listener);
114
+ emit("on", -10);
115
+ emit("off", 320);
116
+ emit("unexpected", 320);
117
+
118
+ expect(listener).toHaveBeenNthCalledWith(2, { visible: true, height: 0 });
119
+ expect(listener).toHaveBeenNthCalledWith(3, { visible: false, height: 0 });
120
+ expect(listener).toHaveBeenNthCalledWith(4, { visible: false, height: 0 });
121
+ });
122
+ });
@@ -0,0 +1,75 @@
1
+ import type { GlobalEventEmitter } from "@lynx-js/types";
2
+
3
+ import type { RawKeyboardState } from "./native-driver";
4
+
5
+ export type KeyboardEventListener = (state: RawKeyboardState) => void;
6
+
7
+ export interface KeyboardEventSource {
8
+ subscribe(listener: KeyboardEventListener): () => void;
9
+ }
10
+
11
+ type GetGlobalEventEmitter = () => GlobalEventEmitter;
12
+
13
+ export function createKeyboardEventSource(getEmitter: GetGlobalEventEmitter): KeyboardEventSource {
14
+ const subscriptions = new Set<{ listener: KeyboardEventListener }>();
15
+ let emitter: GlobalEventEmitter | null = null;
16
+ let currentState: RawKeyboardState = { visible: false, height: 0 };
17
+
18
+ const handleNativeKeyboardEvent = (...args: unknown[]) => {
19
+ "background only";
20
+
21
+ const status = args[0];
22
+ const height = args[1];
23
+ currentState = {
24
+ visible: status === "on",
25
+ height:
26
+ status === "on" && typeof height === "number" && Number.isFinite(height)
27
+ ? Math.max(0, height)
28
+ : 0,
29
+ };
30
+
31
+ for (const subscription of subscriptions) {
32
+ subscription.listener(currentState);
33
+ }
34
+ };
35
+
36
+ return {
37
+ subscribe(listener) {
38
+ "background only";
39
+
40
+ const subscription = { listener };
41
+ subscriptions.add(subscription);
42
+
43
+ if (emitter === null) {
44
+ try {
45
+ const nextEmitter = getEmitter();
46
+ nextEmitter.addListener("keyboardstatuschanged", handleNativeKeyboardEvent);
47
+ emitter = nextEmitter;
48
+ } catch {
49
+ // GlobalEventEmitter가 없는 host에서는 기본 닫힘 상태로 fail-soft 처리한다.
50
+ }
51
+ }
52
+
53
+ listener(currentState);
54
+
55
+ let subscribed = true;
56
+
57
+ return () => {
58
+ "background only";
59
+
60
+ if (!subscribed) return;
61
+ subscribed = false;
62
+ subscriptions.delete(subscription);
63
+
64
+ // Lynx의 keyboard detection은 listener별 ref-count가 아니다. 모듈 수명 동안
65
+ // 단일 native listener를 유지해 다른 구독자와 간섭하지 않고 최신 상태도 보존한다.
66
+ };
67
+ },
68
+ };
69
+ }
70
+
71
+ export const lynxKeyboardEventSource = createKeyboardEventSource(() => {
72
+ "background only";
73
+
74
+ return lynx.getJSModule("GlobalEventEmitter");
75
+ });