@seed-design/lynx-react 0.3.0 → 0.4.0

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 (84) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +20 -0
  3. package/lib/components/Field/Field.d.ts +47 -0
  4. package/lib/components/Field/Field.d.ts.map +1 -0
  5. package/lib/components/Field/Field.jsx +121 -0
  6. package/lib/components/Field/Field.namespace.d.ts +2 -0
  7. package/lib/components/Field/Field.namespace.d.ts.map +1 -0
  8. package/lib/components/Field/Field.namespace.js +16 -0
  9. package/lib/components/Field/context.d.ts +19 -0
  10. package/lib/components/Field/context.d.ts.map +1 -0
  11. package/lib/components/Field/context.js +10 -0
  12. package/lib/components/Field/index.d.ts +3 -0
  13. package/lib/components/Field/index.d.ts.map +1 -0
  14. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts +42 -0
  15. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts.map +1 -0
  16. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.jsx +140 -0
  17. package/lib/components/KeyboardAvoidingScrollView/context.d.ts +17 -0
  18. package/lib/components/KeyboardAvoidingScrollView/context.d.ts.map +1 -0
  19. package/lib/components/KeyboardAvoidingScrollView/context.js +12 -0
  20. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts +50 -0
  21. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts.map +1 -0
  22. package/lib/components/KeyboardAvoidingScrollView/engine.js +259 -0
  23. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts +50 -0
  24. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts.map +1 -0
  25. package/lib/components/KeyboardAvoidingScrollView/geometry.js +90 -0
  26. package/lib/components/KeyboardAvoidingScrollView/index.d.ts +2 -0
  27. package/lib/components/KeyboardAvoidingScrollView/index.d.ts.map +1 -0
  28. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts +11 -0
  29. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts.map +1 -0
  30. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.js +43 -0
  31. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts +34 -0
  32. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts.map +1 -0
  33. package/lib/components/KeyboardAvoidingScrollView/native-driver.js +133 -0
  34. package/lib/components/TextField/TextField.d.ts +113 -0
  35. package/lib/components/TextField/TextField.d.ts.map +1 -0
  36. package/lib/components/TextField/TextField.jsx +418 -0
  37. package/lib/components/TextField/TextField.namespace.d.ts +2 -0
  38. package/lib/components/TextField/TextField.namespace.d.ts.map +1 -0
  39. package/lib/components/TextField/TextField.namespace.js +14 -0
  40. package/lib/components/TextField/context.d.ts +22 -0
  41. package/lib/components/TextField/context.d.ts.map +1 -0
  42. package/lib/components/TextField/context.js +7 -0
  43. package/lib/components/TextField/index.d.ts +4 -0
  44. package/lib/components/TextField/index.d.ts.map +1 -0
  45. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts +24 -0
  46. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -0
  47. package/lib/components/TextField/useTextFieldWithGraphemes.js +47 -0
  48. package/lib/components/index.d.ts +3 -0
  49. package/lib/components/index.d.ts.map +1 -1
  50. package/lib/index.js +7 -1
  51. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_grapheme_data.js +8 -0
  52. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js +14 -0
  53. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js +68 -0
  54. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js +175 -0
  55. package/lib/utils/create-slot-recipe-context.d.ts.map +1 -1
  56. package/lib/utils/create-slot-recipe-context.jsx +7 -0
  57. package/package.json +10 -5
  58. package/src/components/Field/Field.namespace.ts +19 -0
  59. package/src/components/Field/Field.test.tsx +81 -0
  60. package/src/components/Field/Field.tsx +252 -0
  61. package/src/components/Field/context.ts +26 -0
  62. package/src/components/Field/index.ts +21 -0
  63. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.test.tsx +267 -0
  64. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +299 -0
  65. package/src/components/KeyboardAvoidingScrollView/context.ts +24 -0
  66. package/src/components/KeyboardAvoidingScrollView/engine.test.ts +451 -0
  67. package/src/components/KeyboardAvoidingScrollView/engine.ts +417 -0
  68. package/src/components/KeyboardAvoidingScrollView/geometry.test.ts +315 -0
  69. package/src/components/KeyboardAvoidingScrollView/geometry.ts +194 -0
  70. package/src/components/KeyboardAvoidingScrollView/index.ts +5 -0
  71. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.test.ts +122 -0
  72. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.ts +75 -0
  73. package/src/components/KeyboardAvoidingScrollView/native-driver.test.ts +223 -0
  74. package/src/components/KeyboardAvoidingScrollView/native-driver.ts +248 -0
  75. package/src/components/TextField/TextField.namespace.ts +16 -0
  76. package/src/components/TextField/TextField.test.tsx +924 -0
  77. package/src/components/TextField/TextField.tsx +912 -0
  78. package/src/components/TextField/context.ts +33 -0
  79. package/src/components/TextField/index.ts +23 -0
  80. package/src/components/TextField/useTextFieldWithGraphemes.test.tsx +80 -0
  81. package/src/components/TextField/useTextFieldWithGraphemes.ts +78 -0
  82. package/src/components/index.ts +3 -0
  83. package/src/utils/create-slot-recipe-context.test.tsx +51 -0
  84. package/src/utils/create-slot-recipe-context.tsx +23 -0
@@ -0,0 +1,417 @@
1
+ import {
2
+ calculateSafeArea,
3
+ calculateSignedScrollDelta,
4
+ clampScrollOffset,
5
+ createFieldBodyRect,
6
+ hasMeaningfulGeometryChange,
7
+ selectLargestFittingTarget,
8
+ type AvoidanceTargetKind,
9
+ type VerticalRect,
10
+ } from "./geometry";
11
+ import type { KeyboardOcclusion, RawKeyboardState, ScrollMetrics } from "./native-driver";
12
+
13
+ const BLUR_HANDOFF_DELAY_MS = 30;
14
+
15
+ export interface RefLike<Node> {
16
+ current: Node | null;
17
+ }
18
+
19
+ export interface KeyboardAvoidanceRegistration<Node> {
20
+ owner: object;
21
+ nativeRef: RefLike<Node>;
22
+ controlRef?: RefLike<Node>;
23
+ fieldRef?: RefLike<Node>;
24
+ anchorRef?: RefLike<Node>;
25
+ enabled?: boolean;
26
+ }
27
+
28
+ export interface KeyboardAvoidingNativeDriver<Node> {
29
+ measure(node: Node): Promise<VerticalRect | null>;
30
+ resolveKeyboardOcclusion(state: RawKeyboardState): Promise<KeyboardOcclusion | null>;
31
+ setSpacerHeight(node: Node, height: number): void;
32
+ waitForLayout(): Promise<void>;
33
+ getScrollMetrics(node: Node, viewportHeight: number): Promise<ScrollMetrics | null>;
34
+ scrollTo(node: Node, offset: number, smooth: boolean): void;
35
+ }
36
+
37
+ type ScheduledCallback = () => void | Promise<void>;
38
+ type CancelScheduledCallback = () => void;
39
+
40
+ export interface KeyboardAvoidingScheduler {
41
+ scheduleFrame(callback: ScheduledCallback): CancelScheduledCallback;
42
+ scheduleTimer(callback: ScheduledCallback, delayMs: number): CancelScheduledCallback;
43
+ }
44
+
45
+ export interface KeyboardAvoidingEngineOptions<Node> {
46
+ driver: KeyboardAvoidingNativeDriver<Node>;
47
+ scheduler: KeyboardAvoidingScheduler;
48
+ getScrollNode(): Node | null;
49
+ getSpacerNode(): Node | null;
50
+ getKeyboardGap(): number;
51
+ getToolbarHeight(): number;
52
+ getSmooth(): boolean;
53
+ }
54
+
55
+ export interface KeyboardAvoidingEngine<Node> {
56
+ focus(registration: KeyboardAvoidanceRegistration<Node>): void;
57
+ blur(owner: object): void;
58
+ layoutChanged(owner: object): void;
59
+ unregister(owner: object): void;
60
+ keyboardChanged(state: RawKeyboardState): void;
61
+ viewportChanged(): void;
62
+ userScrollStarted(): void;
63
+ userScrollEnded(): void;
64
+ dispose(): void;
65
+ }
66
+
67
+ type InvalidationReason = "focus" | "keyboard" | "layout" | "scroll" | "viewport";
68
+
69
+ interface OwnerSessionState {
70
+ session: number;
71
+ fieldDowngraded: boolean;
72
+ lastTargetKind: AvoidanceTargetKind | null;
73
+ lastTargetHeight: number | null;
74
+ }
75
+
76
+ interface Transaction<Node> {
77
+ revision: number;
78
+ session: number;
79
+ registration: KeyboardAvoidanceRegistration<Node> | null;
80
+ keyboard: RawKeyboardState;
81
+ layoutOnly: boolean;
82
+ }
83
+
84
+ function normalizeKeyboardState(state: RawKeyboardState): RawKeyboardState {
85
+ const height = Number.isFinite(state.height) ? Math.max(0, state.height) : 0;
86
+ const visible = state.visible && height > 0;
87
+
88
+ return { visible, height: visible ? height : 0 };
89
+ }
90
+
91
+ function targetHeight(rect: VerticalRect): number {
92
+ return Math.max(0, rect.bottom - rect.top);
93
+ }
94
+
95
+ class KeyboardAvoidingEngineImpl<Node> implements KeyboardAvoidingEngine<Node> {
96
+ readonly #options: KeyboardAvoidingEngineOptions<Node>;
97
+ readonly #pendingReasons = new Set<InvalidationReason>();
98
+ readonly #ownerStates = new Map<object, OwnerSessionState>();
99
+
100
+ #activeRegistration: KeyboardAvoidanceRegistration<Node> | null = null;
101
+ #keyboard: RawKeyboardState = { visible: false, height: 0 };
102
+ #revision = 0;
103
+ #session = 0;
104
+ #appliedSpacerHeight = 0;
105
+ #needsCloseClamp = false;
106
+ #cancelFrame: CancelScheduledCallback | null = null;
107
+ #cancelBlur: CancelScheduledCallback | null = null;
108
+ #blurOwner: object | null = null;
109
+ #userScrolling = false;
110
+ #disposed = false;
111
+
112
+ constructor(options: KeyboardAvoidingEngineOptions<Node>) {
113
+ this.#options = options;
114
+ }
115
+
116
+ focus(registration: KeyboardAvoidanceRegistration<Node>): void {
117
+ if (this.#disposed) return;
118
+
119
+ this.#cancelPendingBlur();
120
+ if (registration.enabled === false) {
121
+ if (this.#activeRegistration === null) return;
122
+
123
+ this.#activeRegistration = null;
124
+ this.#invalidate("focus");
125
+ return;
126
+ }
127
+
128
+ this.#activeRegistration = registration;
129
+ this.#invalidate("focus");
130
+ }
131
+
132
+ blur(owner: object): void {
133
+ if (this.#disposed || this.#activeRegistration?.owner !== owner) return;
134
+
135
+ this.#cancelPendingBlur();
136
+ this.#blurOwner = owner;
137
+ this.#cancelBlur = this.#options.scheduler.scheduleTimer(() => {
138
+ this.#cancelBlur = null;
139
+ const blurOwner = this.#blurOwner;
140
+ this.#blurOwner = null;
141
+ if (this.#activeRegistration?.owner !== blurOwner) return;
142
+
143
+ this.#activeRegistration = null;
144
+ this.#invalidate("focus");
145
+ }, BLUR_HANDOFF_DELAY_MS);
146
+ }
147
+
148
+ layoutChanged(owner: object): void {
149
+ if (this.#disposed || this.#activeRegistration?.owner !== owner) return;
150
+ this.#invalidate("layout");
151
+ }
152
+
153
+ unregister(owner: object): void {
154
+ if (this.#disposed) return;
155
+
156
+ this.#ownerStates.delete(owner);
157
+ if (this.#blurOwner === owner) this.#cancelPendingBlur();
158
+ if (this.#activeRegistration?.owner !== owner) return;
159
+
160
+ this.#activeRegistration = null;
161
+ this.#invalidate("focus");
162
+ }
163
+
164
+ keyboardChanged(state: RawKeyboardState): void {
165
+ if (this.#disposed) return;
166
+
167
+ const next = normalizeKeyboardState(state);
168
+ if (
169
+ next.visible === this.#keyboard.visible &&
170
+ !hasMeaningfulGeometryChange(this.#keyboard.height, next.height)
171
+ ) {
172
+ return;
173
+ }
174
+
175
+ if (next.visible !== this.#keyboard.visible) {
176
+ this.#session += 1;
177
+ this.#ownerStates.clear();
178
+ }
179
+ this.#keyboard = next;
180
+ this.#invalidate("keyboard");
181
+ }
182
+
183
+ viewportChanged(): void {
184
+ if (this.#disposed) return;
185
+ this.#invalidate("viewport");
186
+ }
187
+
188
+ userScrollStarted(): void {
189
+ if (this.#disposed || this.#userScrolling) return;
190
+
191
+ this.#userScrolling = true;
192
+ this.#revision += 1;
193
+ this.#pendingReasons.add("scroll");
194
+ this.#cancelFrame?.();
195
+ this.#cancelFrame = null;
196
+ }
197
+
198
+ userScrollEnded(): void {
199
+ if (this.#disposed || !this.#userScrolling) return;
200
+
201
+ this.#userScrolling = false;
202
+ this.#invalidate("scroll");
203
+ }
204
+
205
+ dispose(): void {
206
+ if (this.#disposed) return;
207
+
208
+ this.#disposed = true;
209
+ this.#revision += 1;
210
+ this.#cancelFrame?.();
211
+ this.#cancelFrame = null;
212
+ this.#cancelPendingBlur();
213
+ this.#pendingReasons.clear();
214
+ this.#ownerStates.clear();
215
+ this.#activeRegistration = null;
216
+ }
217
+
218
+ #cancelPendingBlur(): void {
219
+ this.#cancelBlur?.();
220
+ this.#cancelBlur = null;
221
+ this.#blurOwner = null;
222
+ }
223
+
224
+ #invalidate(reason: InvalidationReason): void {
225
+ this.#revision += 1;
226
+ this.#pendingReasons.add(reason);
227
+ if (this.#userScrolling || this.#cancelFrame !== null) return;
228
+
229
+ this.#cancelFrame = this.#options.scheduler.scheduleFrame(async () => {
230
+ this.#cancelFrame = null;
231
+ if (this.#disposed || this.#userScrolling) return;
232
+
233
+ const reasons = new Set(this.#pendingReasons);
234
+ this.#pendingReasons.clear();
235
+ const transaction: Transaction<Node> = {
236
+ revision: this.#revision,
237
+ session: this.#session,
238
+ registration: this.#activeRegistration,
239
+ keyboard: this.#keyboard,
240
+ layoutOnly: reasons.size === 1 && reasons.has("layout"),
241
+ };
242
+
243
+ await this.#evaluate(transaction);
244
+ });
245
+ }
246
+
247
+ #isCurrent(transaction: Transaction<Node>): boolean {
248
+ return (
249
+ !this.#disposed &&
250
+ transaction.revision === this.#revision &&
251
+ transaction.session === this.#session &&
252
+ transaction.registration === this.#activeRegistration &&
253
+ !this.#userScrolling
254
+ );
255
+ }
256
+
257
+ async #evaluate(transaction: Transaction<Node>): Promise<void> {
258
+ try {
259
+ if (!transaction.keyboard.visible || transaction.registration === null) {
260
+ await this.#close(transaction);
261
+ return;
262
+ }
263
+
264
+ await this.#open(transaction, transaction.registration);
265
+ } catch {
266
+ // Native 측정 실패는 현재 transaction만 중단하고 다음 invalidation에서 재시도한다.
267
+ }
268
+ }
269
+
270
+ async #close(transaction: Transaction<Node>): Promise<void> {
271
+ if (this.#appliedSpacerHeight === 0 && !this.#needsCloseClamp) return;
272
+
273
+ const scrollNode = this.#options.getScrollNode();
274
+ if (this.#appliedSpacerHeight !== 0) {
275
+ const spacerNode = this.#options.getSpacerNode();
276
+ if (spacerNode === null) return;
277
+
278
+ this.#options.driver.setSpacerHeight(spacerNode, 0);
279
+ this.#appliedSpacerHeight = 0;
280
+ this.#needsCloseClamp = true;
281
+ }
282
+ if (scrollNode === null) return;
283
+
284
+ await this.#options.driver.waitForLayout();
285
+ if (!this.#isCurrent(transaction)) return;
286
+
287
+ const viewport = await this.#options.driver.measure(scrollNode);
288
+ if (!this.#isCurrent(transaction) || viewport === null) return;
289
+
290
+ const viewportHeight = Math.max(0, viewport.bottom - viewport.top);
291
+ const metrics = await this.#options.driver.getScrollMetrics(scrollNode, viewportHeight);
292
+ if (!this.#isCurrent(transaction) || metrics === null) return;
293
+
294
+ const clampedOffset = clampScrollOffset(metrics.offsetY, metrics.maxOffsetY);
295
+ if (clampedOffset === metrics.offsetY) {
296
+ this.#needsCloseClamp = false;
297
+ return;
298
+ }
299
+
300
+ this.#options.driver.scrollTo(scrollNode, clampedOffset, this.#options.getSmooth());
301
+ this.#needsCloseClamp = false;
302
+ }
303
+
304
+ async #open(
305
+ transaction: Transaction<Node>,
306
+ registration: KeyboardAvoidanceRegistration<Node>,
307
+ ): Promise<void> {
308
+ const scrollNode = this.#options.getScrollNode();
309
+ const spacerNode = this.#options.getSpacerNode();
310
+ if (scrollNode === null || spacerNode === null) return;
311
+
312
+ const [viewport, occlusion] = await Promise.all([
313
+ this.#options.driver.measure(scrollNode),
314
+ this.#options.driver.resolveKeyboardOcclusion(transaction.keyboard),
315
+ ]);
316
+ if (!this.#isCurrent(transaction) || viewport === null || occlusion === null) return;
317
+
318
+ const safeArea = calculateSafeArea({
319
+ viewport,
320
+ keyboardOcclusionTop: occlusion.topInScreenPx,
321
+ toolbarHeight: this.#options.getToolbarHeight(),
322
+ keyboardGap: this.#options.getKeyboardGap(),
323
+ });
324
+ this.#needsCloseClamp = false;
325
+
326
+ if (hasMeaningfulGeometryChange(this.#appliedSpacerHeight, safeArea.spacerHeight)) {
327
+ this.#options.driver.setSpacerHeight(spacerNode, safeArea.spacerHeight);
328
+ this.#appliedSpacerHeight = safeArea.spacerHeight;
329
+ }
330
+
331
+ await this.#options.driver.waitForLayout();
332
+ if (!this.#isCurrent(transaction)) return;
333
+
334
+ const state = this.#getOwnerState(registration.owner, transaction.session);
335
+ const measure = (ref: RefLike<Node> | undefined): Promise<VerticalRect | null> => {
336
+ const node = ref?.current;
337
+ if (node === null || node === undefined) return Promise.resolve(null);
338
+ return Promise.resolve()
339
+ .then(() => this.#options.driver.measure(node))
340
+ .catch(() => null);
341
+ };
342
+ const [field, control, native, anchor] = await Promise.all([
343
+ state.fieldDowngraded ? Promise.resolve(null) : measure(registration.fieldRef),
344
+ measure(registration.controlRef),
345
+ measure(registration.nativeRef),
346
+ measure(registration.anchorRef),
347
+ ]);
348
+ if (!this.#isCurrent(transaction)) return;
349
+
350
+ const fieldBody = createFieldBodyRect(field, control);
351
+ const selected = selectLargestFittingTarget(
352
+ { field, fieldBody, control, native, anchor },
353
+ safeArea,
354
+ );
355
+ const nextState: OwnerSessionState = {
356
+ ...state,
357
+ fieldDowngraded:
358
+ state.fieldDowngraded ||
359
+ (fieldBody === null && field !== null && selected !== null && selected.kind !== "field"),
360
+ lastTargetKind: selected?.kind ?? null,
361
+ lastTargetHeight: selected ? targetHeight(selected.rect) : null,
362
+ };
363
+
364
+ if (selected === null) {
365
+ this.#ownerStates.set(registration.owner, nextState);
366
+ return;
367
+ }
368
+
369
+ if (
370
+ transaction.layoutOnly &&
371
+ ((state.lastTargetKind === selected.kind &&
372
+ state.lastTargetHeight !== null &&
373
+ targetHeight(selected.rect) < state.lastTargetHeight - 1) ||
374
+ (state.lastTargetKind === "fieldBottom" && selected.kind !== "fieldBottom"))
375
+ ) {
376
+ this.#ownerStates.set(registration.owner, nextState);
377
+ return;
378
+ }
379
+
380
+ const delta = calculateSignedScrollDelta(selected.rect, safeArea);
381
+ if (delta === 0) {
382
+ this.#ownerStates.set(registration.owner, nextState);
383
+ return;
384
+ }
385
+
386
+ const viewportHeight = Math.max(0, viewport.bottom - viewport.top);
387
+ const metrics = await this.#options.driver.getScrollMetrics(scrollNode, viewportHeight);
388
+ if (!this.#isCurrent(transaction) || metrics === null) return;
389
+
390
+ const nextOffset = clampScrollOffset(metrics.offsetY + delta, metrics.maxOffsetY);
391
+ if (!hasMeaningfulGeometryChange(metrics.offsetY, nextOffset)) {
392
+ this.#ownerStates.set(registration.owner, nextState);
393
+ return;
394
+ }
395
+
396
+ this.#options.driver.scrollTo(scrollNode, nextOffset, this.#options.getSmooth());
397
+ this.#ownerStates.set(registration.owner, nextState);
398
+ }
399
+
400
+ #getOwnerState(owner: object, session: number): OwnerSessionState {
401
+ const current = this.#ownerStates.get(owner);
402
+ if (current?.session === session) return current;
403
+
404
+ return {
405
+ session,
406
+ fieldDowngraded: false,
407
+ lastTargetKind: null,
408
+ lastTargetHeight: null,
409
+ };
410
+ }
411
+ }
412
+
413
+ export function createKeyboardAvoidingEngine<Node>(
414
+ options: KeyboardAvoidingEngineOptions<Node>,
415
+ ): KeyboardAvoidingEngine<Node> {
416
+ return new KeyboardAvoidingEngineImpl(options);
417
+ }