@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,924 @@
1
+ import "@testing-library/jest-dom";
2
+ import { createRef, useState } from "@lynx-js/react";
3
+ import { fireEvent, getQueriesForElement, render } from "@lynx-js/react/testing-library";
4
+ import type { NodesRef } from "@lynx-js/types";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { Field } from "../Field";
8
+ import { KeyboardAvoidanceActionsContext } from "../KeyboardAvoidingScrollView/context";
9
+ import { NATIVE_TEXT_MAX_LENGTH_UNLIMITED } from "./context";
10
+ import { TextField } from "./index";
11
+
12
+ type TestSystemInfo = { platform?: string };
13
+
14
+ interface TestLynxGlobal {
15
+ SystemInfo?: TestSystemInfo;
16
+ lynxTestingEnv?: {
17
+ backgroundThread: { globalThis: TestLynxGlobal };
18
+ mainThread: { globalThis: TestLynxGlobal };
19
+ };
20
+ }
21
+
22
+ function setSystemInfo(systemInfo: TestSystemInfo | undefined) {
23
+ const lynxTestingEnv = (globalThis as TestLynxGlobal).lynxTestingEnv;
24
+ const globals = [
25
+ globalThis as TestLynxGlobal,
26
+ lynxTestingEnv?.backgroundThread.globalThis,
27
+ lynxTestingEnv?.mainThread.globalThis,
28
+ ].filter((global): global is TestLynxGlobal => Boolean(global));
29
+
30
+ for (const global of globals) {
31
+ if (systemInfo == null) {
32
+ delete global.SystemInfo;
33
+ } else {
34
+ global.SystemInfo = systemInfo;
35
+ }
36
+ }
37
+ }
38
+
39
+ function getRenderedRoot() {
40
+ const root = elementTree.root;
41
+
42
+ if (!root) {
43
+ throw new Error("Expected Lynx render root to exist.");
44
+ }
45
+
46
+ return root;
47
+ }
48
+
49
+ function getRenderedQueries() {
50
+ return getQueriesForElement(getRenderedRoot());
51
+ }
52
+
53
+ function getTextFieldRoot() {
54
+ const root = getRenderedRoot();
55
+ const textFieldRoot = root.classList.contains("seed-text-input__root")
56
+ ? root
57
+ : root.querySelector<HTMLElement>(".seed-text-input__root");
58
+
59
+ if (!textFieldRoot) {
60
+ throw new Error("Expected TextField root to exist.");
61
+ }
62
+
63
+ return textFieldRoot;
64
+ }
65
+
66
+ function fireNativeEvent(nativeRef: NodesRef, element: Element, eventName: string, detail: object) {
67
+ const EventConstructor = element.ownerDocument.defaultView?.CustomEvent;
68
+ if (!EventConstructor) throw new Error("Expected CustomEvent constructor to exist.");
69
+
70
+ const event = new EventConstructor(`bindEvent:${eventName}`, { bubbles: true, detail });
71
+ Object.assign(event, { eventType: "bindEvent", eventName });
72
+ fireEvent(nativeRef as unknown as Element, event);
73
+ }
74
+
75
+ async function flushControlledReconciliation() {
76
+ await Promise.resolve();
77
+ await Promise.resolve();
78
+ }
79
+
80
+ afterEach(() => {
81
+ setSystemInfo(undefined);
82
+ vi.unstubAllGlobals();
83
+ });
84
+
85
+ describe("TextField", () => {
86
+ it("renders root and text adornment slots with default classes", () => {
87
+ render(
88
+ <TextField.Root className="custom-text-field">
89
+ <TextField.PrefixText>₩</TextField.PrefixText>
90
+ <TextField.SuffixText>원</TextField.SuffixText>
91
+ </TextField.Root>,
92
+ );
93
+
94
+ const root = getTextFieldRoot();
95
+ const { getByText } = getRenderedQueries();
96
+
97
+ expect(root).toHaveClass("custom-text-field");
98
+ expect(root).toHaveClass("seed-text-input__root--variant_outline");
99
+ expect(root).toHaveClass("seed-text-input__root--size_large");
100
+ expect(root.querySelector(".seed-text-input__baseStroke")).toHaveAttribute(
101
+ "accessibility-elements-hidden",
102
+ "true",
103
+ );
104
+ expect(root.querySelector(".seed-text-input__stroke")).toHaveAttribute(
105
+ "accessibility-elements-hidden",
106
+ "true",
107
+ );
108
+ expect(getByText("₩")).toHaveClass("seed-text-input__prefixText");
109
+ expect(getByText("원")).toHaveClass("seed-text-input__suffixText");
110
+ });
111
+
112
+ it("applies explicit visual and state variants", () => {
113
+ render(
114
+ <TextField.Root variant="underline" size="medium" invalid disabled>
115
+ <TextField.PrefixText>앞</TextField.PrefixText>
116
+ <TextField.SuffixText>뒤</TextField.SuffixText>
117
+ </TextField.Root>,
118
+ );
119
+
120
+ const root = getTextFieldRoot();
121
+ const { getByText } = getRenderedQueries();
122
+ const stroke = root.querySelector(".seed-text-input__stroke");
123
+
124
+ expect(root).toHaveClass("seed-text-input__root--variant_underline");
125
+ expect(root).toHaveClass("seed-text-input__root--size_medium");
126
+ expect(root).toHaveClass("seed-text-input__root--invalid_true");
127
+ expect(stroke).toHaveClass("seed-text-input__stroke--variant_underline");
128
+ expect(stroke).toHaveClass("seed-text-input__stroke--invalid_true");
129
+ expect(getByText("앞")).toHaveClass("seed-text-input__prefixText--disabled_true");
130
+ expect(getByText("뒤")).toHaveClass("seed-text-input__suffixText--disabled_true");
131
+ });
132
+
133
+ it("inherits field states when TextField.Root does not override them", () => {
134
+ render(
135
+ <Field.Root invalid disabled readOnly required>
136
+ <TextField.Root>
137
+ <TextField.PrefixText>상태</TextField.PrefixText>
138
+ </TextField.Root>
139
+ </Field.Root>,
140
+ );
141
+
142
+ const root = getTextFieldRoot();
143
+ const { getByText } = getRenderedQueries();
144
+
145
+ expect(root).toHaveClass("seed-text-input__root--invalid_true");
146
+ expect(root).toHaveClass("seed-text-input__root--readOnly_true");
147
+ expect(root).toHaveClass("seed-text-input__root--disabled_true");
148
+ expect(getByText("상태")).toHaveClass("seed-text-input__prefixText--disabled_true");
149
+ });
150
+
151
+ it("throws when an adornment is rendered outside TextField.Root", () => {
152
+ expect(() => render(<TextField.PrefixText>앞</TextField.PrefixText>)).toThrow(
153
+ /ClassNamesProvider/,
154
+ );
155
+ });
156
+
157
+ it("renders a native input with root-owned native props and initial value", () => {
158
+ const onValueChange = vi.fn();
159
+ const inputRef = createRef<NodesRef>();
160
+ const renderTextField = () => (
161
+ <TextField.Root defaultValue="초기값" name="title" onValueChange={onValueChange}>
162
+ <TextField.Input ref={inputRef} placeholder="제목" />
163
+ </TextField.Root>
164
+ );
165
+ const { rerender } = render(renderTextField());
166
+
167
+ const input = getRenderedRoot().querySelector("input");
168
+ if (!input) throw new Error("Expected native input to exist.");
169
+
170
+ expect(input).toHaveClass("seed-text-input__value");
171
+ expect(input).toHaveAttribute("name", "title");
172
+ expect(input).toHaveAttribute("placeholder", "제목");
173
+ expect(input).toHaveAttribute("default-value", "초기값");
174
+ expect(input).not.toHaveAttribute("android-set-soft-input-mode");
175
+
176
+ rerender(renderTextField());
177
+
178
+ const rerenderedInput = getRenderedRoot().querySelector("input");
179
+ if (!rerenderedInput) throw new Error("Expected native input to exist after rerender.");
180
+ expect(rerenderedInput).toBe(input);
181
+ expect(rerenderedInput).toHaveAttribute("default-value", "초기값");
182
+
183
+ expect(onValueChange).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("passes an empty initial value through the native default-value prop", () => {
187
+ render(
188
+ <TextField.Root>
189
+ <TextField.Input />
190
+ </TextField.Root>,
191
+ );
192
+
193
+ const input = getRenderedRoot().querySelector("input");
194
+ if (!input) throw new Error("Expected native input to exist.");
195
+
196
+ expect(input).toHaveAttribute("default-value", "");
197
+ });
198
+
199
+ it("initializes a non-empty value without invoking a native UI method", () => {
200
+ const invoke = vi.fn(() => ({ exec: vi.fn() }));
201
+ const setRef = (node: NodesRef | null) => {
202
+ if (node) node.invoke = invoke as unknown as NodesRef["invoke"];
203
+ };
204
+
205
+ render(
206
+ <TextField.Root defaultValue="초기값">
207
+ <TextField.Input ref={setRef} />
208
+ </TextField.Root>,
209
+ );
210
+
211
+ expect(getRenderedRoot().querySelector("input")).toHaveAttribute("default-value", "초기값");
212
+ expect(invoke).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("does not reapply an accepted controlled native input value", async () => {
216
+ const inputRef = createRef<NodesRef>();
217
+
218
+ function ControlledInput() {
219
+ const [value, setValue] = useState("초기값");
220
+
221
+ return (
222
+ <TextField.Root value={value} onValueChange={setValue}>
223
+ <TextField.Input ref={inputRef} />
224
+ </TextField.Root>
225
+ );
226
+ }
227
+
228
+ render(<ControlledInput />);
229
+
230
+ const input = getRenderedRoot().querySelector("input");
231
+ if (!input || !inputRef.current) throw new Error("Expected native input to exist.");
232
+
233
+ const invoke = vi.fn(() => ({ exec: vi.fn() }));
234
+ inputRef.current.invoke = invoke as unknown as NodesRef["invoke"];
235
+ fireNativeEvent(inputRef.current, input, "input", {
236
+ value: "수정값",
237
+ selectionStart: 3,
238
+ selectionEnd: 3,
239
+ isComposing: false,
240
+ });
241
+ await flushControlledReconciliation();
242
+
243
+ expect(invoke).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("waits for a parent value update queued in a microtask", async () => {
247
+ const inputRef = createRef<NodesRef>();
248
+
249
+ function ControlledInput() {
250
+ const [value, setValue] = useState("초기값");
251
+
252
+ return (
253
+ <TextField.Root
254
+ value={value}
255
+ onValueChange={(nextValue) => {
256
+ void Promise.resolve().then(() => setValue(nextValue));
257
+ }}
258
+ >
259
+ <TextField.Input ref={inputRef} />
260
+ </TextField.Root>
261
+ );
262
+ }
263
+
264
+ render(<ControlledInput />);
265
+
266
+ const input = getRenderedRoot().querySelector("input");
267
+ if (!input || !inputRef.current) throw new Error("Expected native input to exist.");
268
+
269
+ const invoke = vi.fn(() => ({ exec: vi.fn() }));
270
+ inputRef.current.invoke = invoke as unknown as NodesRef["invoke"];
271
+ fireNativeEvent(inputRef.current, input, "input", {
272
+ value: "수정값",
273
+ selectionStart: 3,
274
+ selectionEnd: 3,
275
+ isComposing: false,
276
+ });
277
+ await flushControlledReconciliation();
278
+
279
+ expect(invoke).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("syncs a controlled value that changes after the initial layout", () => {
283
+ const inputRef = createRef<NodesRef>();
284
+ const renderTextField = (value: string) => (
285
+ <TextField.Root value={value}>
286
+ <TextField.Input ref={inputRef} />
287
+ </TextField.Root>
288
+ );
289
+ const { rerender } = render(renderTextField("초기값"));
290
+
291
+ const input = getRenderedRoot().querySelector("input");
292
+ if (!input || !inputRef.current) throw new Error("Expected native input to exist.");
293
+
294
+ const exec = vi.fn();
295
+ const invoke = vi.fn(() => ({ exec }));
296
+ inputRef.current.invoke = invoke as unknown as NodesRef["invoke"];
297
+ rerender(renderTextField("외부 변경값"));
298
+
299
+ expect(invoke).toHaveBeenCalledWith(
300
+ expect.objectContaining({
301
+ method: "setValue",
302
+ params: { value: "외부 변경값" },
303
+ }),
304
+ );
305
+ expect(exec).toHaveBeenCalledTimes(1);
306
+ });
307
+
308
+ it("restores a rejected controlled value before the next frame", async () => {
309
+ const inputRef = createRef<NodesRef>();
310
+ render(
311
+ <TextField.Root value="고정값">
312
+ <TextField.Input ref={inputRef} />
313
+ </TextField.Root>,
314
+ );
315
+
316
+ const input = getRenderedRoot().querySelector("input");
317
+ if (!input || !inputRef.current) throw new Error("Expected native input to exist.");
318
+
319
+ const exec = vi.fn();
320
+ const invoke = vi.fn(() => ({ exec }));
321
+ inputRef.current.invoke = invoke as unknown as NodesRef["invoke"];
322
+ fireNativeEvent(inputRef.current, input, "input", {
323
+ value: "거부할 값",
324
+ selectionStart: 4,
325
+ selectionEnd: 4,
326
+ isComposing: false,
327
+ });
328
+
329
+ expect(invoke).not.toHaveBeenCalled();
330
+ await flushControlledReconciliation();
331
+
332
+ expect(invoke).toHaveBeenCalledWith(
333
+ expect.objectContaining({
334
+ method: "setValue",
335
+ params: { value: "고정값" },
336
+ }),
337
+ );
338
+ expect(exec).toHaveBeenCalledTimes(1);
339
+ });
340
+
341
+ it("syncs a transformed controlled value without restoring the stale value", async () => {
342
+ const inputRef = createRef<NodesRef>();
343
+
344
+ function ControlledInput() {
345
+ const [value, setValue] = useState("INITIAL");
346
+
347
+ return (
348
+ <TextField.Root
349
+ value={value}
350
+ onValueChange={(nextValue) => setValue(nextValue.toUpperCase())}
351
+ >
352
+ <TextField.Input ref={inputRef} />
353
+ </TextField.Root>
354
+ );
355
+ }
356
+
357
+ render(<ControlledInput />);
358
+
359
+ const input = getRenderedRoot().querySelector("input");
360
+ if (!input || !inputRef.current) throw new Error("Expected native input to exist.");
361
+
362
+ const invoke = vi.fn(() => ({ exec: vi.fn() }));
363
+ inputRef.current.invoke = invoke as unknown as NodesRef["invoke"];
364
+ fireNativeEvent(inputRef.current, input, "input", {
365
+ value: "changed",
366
+ selectionStart: 7,
367
+ selectionEnd: 7,
368
+ isComposing: false,
369
+ });
370
+ await flushControlledReconciliation();
371
+
372
+ expect(invoke).toHaveBeenCalledTimes(1);
373
+ expect(invoke).toHaveBeenCalledWith(
374
+ expect.objectContaining({
375
+ method: "setValue",
376
+ params: { value: "CHANGED" },
377
+ }),
378
+ );
379
+ });
380
+
381
+ it("does not reconcile a controlled input after it unmounts", async () => {
382
+ const inputRef = createRef<NodesRef>();
383
+ const { unmount } = render(
384
+ <TextField.Root value="고정값">
385
+ <TextField.Input ref={inputRef} />
386
+ </TextField.Root>,
387
+ );
388
+
389
+ const input = getRenderedRoot().querySelector("input");
390
+ if (!input || !inputRef.current) throw new Error("Expected native input to exist.");
391
+
392
+ const invoke = vi.fn(() => ({ exec: vi.fn() }));
393
+ inputRef.current.invoke = invoke as unknown as NodesRef["invoke"];
394
+ fireNativeEvent(inputRef.current, input, "input", {
395
+ value: "거부할 값",
396
+ selectionStart: 4,
397
+ selectionEnd: 4,
398
+ isComposing: false,
399
+ });
400
+ unmount();
401
+ await flushControlledReconciliation();
402
+
403
+ expect(invoke).not.toHaveBeenCalled();
404
+ });
405
+
406
+ it("does not resync a disabled value after setValue emits the accepted input event", () => {
407
+ const inputRef = createRef<NodesRef>();
408
+ render(
409
+ <TextField.Root defaultValue="고정값" disabled>
410
+ <TextField.Input ref={inputRef} />
411
+ </TextField.Root>,
412
+ );
413
+
414
+ const input = getRenderedRoot().querySelector("input");
415
+ if (!input || !inputRef.current) throw new Error("Expected native input to exist.");
416
+
417
+ const exec = vi.fn();
418
+ const invoke = vi.fn(() => ({ exec }));
419
+ inputRef.current.invoke = invoke as unknown as NodesRef["invoke"];
420
+
421
+ fireNativeEvent(inputRef.current, input, "input", {
422
+ value: "변경값",
423
+ selectionStart: 3,
424
+ selectionEnd: 3,
425
+ isComposing: false,
426
+ });
427
+
428
+ expect(invoke).toHaveBeenCalledTimes(1);
429
+ expect(invoke).toHaveBeenCalledWith(
430
+ expect.objectContaining({
431
+ method: "setValue",
432
+ params: { value: "고정값" },
433
+ }),
434
+ );
435
+
436
+ fireNativeEvent(inputRef.current, input, "input", {
437
+ value: "고정값",
438
+ selectionStart: 3,
439
+ selectionEnd: 3,
440
+ isComposing: false,
441
+ });
442
+
443
+ expect(invoke).toHaveBeenCalledTimes(1);
444
+ expect(exec).toHaveBeenCalledTimes(1);
445
+ });
446
+
447
+ it("applies the native insertion cap only to collapsed input selections", () => {
448
+ const inputRef = createRef<NodesRef>();
449
+ render(
450
+ <TextField.Root nativeInsertionMaxLength={12}>
451
+ <TextField.Input ref={inputRef} />
452
+ </TextField.Root>,
453
+ );
454
+
455
+ const input = getRenderedRoot().querySelector("input");
456
+ if (!input) throw new Error("Expected native input to exist.");
457
+
458
+ expect(input).toHaveAttribute("maxlength", "12");
459
+
460
+ if (!inputRef.current) throw new Error("Expected native input ref to exist.");
461
+
462
+ fireNativeEvent(inputRef.current, input, "selection", { selectionStart: 0, selectionEnd: 2 });
463
+ expect(input).toHaveAttribute("maxlength", String(NATIVE_TEXT_MAX_LENGTH_UNLIMITED));
464
+
465
+ fireNativeEvent(inputRef.current, input, "selection", { selectionStart: 2, selectionEnd: 2 });
466
+ expect(input).toHaveAttribute("maxlength", "12");
467
+ });
468
+
469
+ it("preserves a stricter explicit input maxlength for selection replacement", () => {
470
+ const inputRef = createRef<NodesRef>();
471
+ render(
472
+ <TextField.Root nativeInsertionMaxLength={12}>
473
+ <TextField.Input ref={inputRef} maxlength={8} />
474
+ </TextField.Root>,
475
+ );
476
+
477
+ const input = getRenderedRoot().querySelector("input");
478
+ if (!input) throw new Error("Expected native input to exist.");
479
+
480
+ expect(input).toHaveAttribute("maxlength", "8");
481
+
482
+ if (!inputRef.current) throw new Error("Expected native input ref to exist.");
483
+
484
+ fireNativeEvent(inputRef.current, input, "selection", { selectionStart: 0, selectionEnd: 2 });
485
+ expect(input).toHaveAttribute("maxlength", "8");
486
+ });
487
+
488
+ it("relaxes a removed root insertion cap without replacing the native input", () => {
489
+ const renderTextField = (nativeInsertionMaxLength?: number) => (
490
+ <TextField.Root nativeInsertionMaxLength={nativeInsertionMaxLength}>
491
+ <TextField.Input />
492
+ </TextField.Root>
493
+ );
494
+ const { rerender } = render(renderTextField(12));
495
+ const input = getRenderedRoot().querySelector("input");
496
+ if (!input) throw new Error("Expected native input to exist.");
497
+
498
+ expect(input).toHaveAttribute("maxlength", "12");
499
+
500
+ rerender(renderTextField());
501
+
502
+ const relaxedInput = getRenderedRoot().querySelector("input");
503
+ expect(relaxedInput).toBe(input);
504
+ expect(relaxedInput).toHaveAttribute("maxlength", String(NATIVE_TEXT_MAX_LENGTH_UNLIMITED));
505
+ });
506
+
507
+ it("relaxes a removed explicit maxlength without replacing the native input", () => {
508
+ const renderTextField = (maxlength?: number) => (
509
+ <TextField.Root>
510
+ <TextField.Input maxlength={maxlength} />
511
+ </TextField.Root>
512
+ );
513
+ const { rerender } = render(renderTextField(8));
514
+ const input = getRenderedRoot().querySelector("input");
515
+ if (!input) throw new Error("Expected native input to exist.");
516
+
517
+ expect(input).toHaveAttribute("maxlength", "8");
518
+
519
+ rerender(renderTextField());
520
+
521
+ const relaxedInput = getRenderedRoot().querySelector("input");
522
+ expect(relaxedInput).toBe(input);
523
+ expect(relaxedInput).toHaveAttribute("maxlength", String(NATIVE_TEXT_MAX_LENGTH_UNLIMITED));
524
+ });
525
+
526
+ it("registers focused inputs with KeyboardAvoidingScrollView context", () => {
527
+ const inputRef = createRef<NodesRef>();
528
+ const actions = {
529
+ focus: vi.fn(),
530
+ blur: vi.fn(),
531
+ layoutChanged: vi.fn(),
532
+ unregister: vi.fn(),
533
+ };
534
+
535
+ render(
536
+ <KeyboardAvoidanceActionsContext.Provider value={actions}>
537
+ <Field.Root>
538
+ <TextField.Root>
539
+ <TextField.Input ref={inputRef} />
540
+ </TextField.Root>
541
+ </Field.Root>
542
+ </KeyboardAvoidanceActionsContext.Provider>,
543
+ );
544
+
545
+ if (!inputRef.current) throw new Error("Expected native input ref to exist.");
546
+ expect(getRenderedRoot().querySelector("input")).not.toHaveAttribute(
547
+ "android-set-soft-input-mode",
548
+ );
549
+
550
+ // ReactLynx Testing Library runtime accepts NodesRef, but its public fireEvent type
551
+ // currently exposes only DOM Element inputs.
552
+ fireEvent.focus(inputRef.current as unknown as Element);
553
+ expect(actions.focus).toHaveBeenCalledOnce();
554
+ expect(actions.focus.mock.calls[0]?.[0]).toMatchObject({
555
+ enabled: true,
556
+ nativeRef: { current: inputRef.current },
557
+ });
558
+ expect(getTextFieldRoot()).toHaveClass("seed-text-input__root--focused_true");
559
+ expect(getTextFieldRoot().querySelector(".seed-text-input__stroke")).toHaveClass(
560
+ "seed-text-input__stroke--focused_true",
561
+ );
562
+
563
+ fireEvent.blur(inputRef.current as unknown as Element);
564
+ expect(actions.blur).toHaveBeenCalledOnce();
565
+ expect(getTextFieldRoot()).toHaveClass("seed-text-input__root--focused_false");
566
+ expect(getTextFieldRoot().querySelector(".seed-text-input__stroke")).toHaveClass(
567
+ "seed-text-input__stroke--focused_false",
568
+ );
569
+ });
570
+
571
+ it("renders readonly input and textarea values as non-editable text", () => {
572
+ const inputRef = createRef<NodesRef>();
573
+ const textareaRef = createRef<NodesRef>();
574
+ render(
575
+ <Field.Root disabled readOnly>
576
+ <TextField.Root defaultValue="고정값">
577
+ <TextField.Input ref={inputRef} />
578
+ <TextField.Textarea ref={textareaRef} />
579
+ </TextField.Root>
580
+ </Field.Root>,
581
+ );
582
+
583
+ const values = getRenderedRoot().querySelectorAll("text.seed-text-input__value");
584
+
585
+ expect(getRenderedRoot().querySelector("input")).toBeNull();
586
+ expect(getRenderedRoot().querySelector("textarea")).toBeNull();
587
+ expect(values).toHaveLength(2);
588
+ expect(values[0]).toHaveTextContent("고정값");
589
+ expect(values[0]).toHaveClass("seed-text-input__value--disabled_true");
590
+ expect(values[1]).toHaveTextContent("고정값");
591
+ expect(values[1]).toHaveClass("seed-text-input__textareaValue");
592
+ expect(values[1]).toHaveClass("seed-text-input__textareaFixed");
593
+ expect(inputRef.current).not.toBeNull();
594
+ expect(textareaRef.current).not.toBeNull();
595
+ });
596
+
597
+ it("renders readonly placeholders as text without native controls", () => {
598
+ render(
599
+ <Field.Root readOnly>
600
+ <TextField.Root>
601
+ <TextField.Input placeholder="한 줄 플레이스홀더" />
602
+ <TextField.Textarea placeholder="여러 줄 플레이스홀더" />
603
+ </TextField.Root>
604
+ </Field.Root>,
605
+ );
606
+
607
+ const { getByText } = getRenderedQueries();
608
+ expect(getByText("한 줄 플레이스홀더")).toHaveStyle({
609
+ color: "var(--seed-color-fg-placeholder)",
610
+ });
611
+ expect(getByText("여러 줄 플레이스홀더")).toHaveStyle({
612
+ color: "var(--seed-color-fg-placeholder)",
613
+ });
614
+ });
615
+
616
+ it("masks a readonly password value rendered as text", () => {
617
+ render(
618
+ <Field.Root readOnly>
619
+ <TextField.Root defaultValue="secret">
620
+ <TextField.Input type="password" />
621
+ </TextField.Root>
622
+ </Field.Root>,
623
+ );
624
+
625
+ expect(getRenderedRoot().querySelector("input")).toBeNull();
626
+ expect(getRenderedQueries().getByText("••••••")).toBeInTheDocument();
627
+ expect(getRenderedQueries().queryByText("secret")).toBeNull();
628
+ });
629
+
630
+ it("uses native intrinsic autoresize inside a padded sizing wrapper", () => {
631
+ const textareaRef = createRef<NodesRef>();
632
+ const actions = {
633
+ focus: vi.fn(),
634
+ blur: vi.fn(),
635
+ layoutChanged: vi.fn(),
636
+ unregister: vi.fn(),
637
+ };
638
+ render(
639
+ <KeyboardAvoidanceActionsContext.Provider value={actions}>
640
+ <TextField.Root defaultValue={"첫 줄\n둘째 줄\n"}>
641
+ <TextField.Textarea
642
+ ref={textareaRef}
643
+ placeholder="내용"
644
+ android-set-soft-input-mode="nothing"
645
+ />
646
+ </TextField.Root>
647
+ </KeyboardAvoidanceActionsContext.Provider>,
648
+ );
649
+
650
+ const root = getRenderedRoot();
651
+ const textarea = root.querySelector("textarea");
652
+ const textareaRoot = root.querySelector(".seed-text-input__textareaRoot");
653
+ if (!textarea) throw new Error("Expected native textarea to exist.");
654
+ if (!textareaRoot) throw new Error("Expected textarea sizing wrapper to exist.");
655
+ if (!textareaRef.current) throw new Error("Expected native textarea ref to exist.");
656
+
657
+ expect(textareaRoot).toHaveClass("seed-text-input__textareaAutoresizeRoot--size_large");
658
+ expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
659
+ expect(textarea).not.toHaveClass("seed-text-input__textareaAndroidAutoresize--size_large");
660
+ expect(textarea).toHaveClass("seed-text-input__textareaValue");
661
+ expect(textarea).not.toHaveClass("seed-text-input__textareaFixed");
662
+ expect(textarea).toHaveAttribute("bounces", "false");
663
+ expect(textarea).toHaveAttribute("default-value", "첫 줄\n둘째 줄\n");
664
+ expect(textarea).toHaveAttribute("android-fullscreen-mode", "false");
665
+ expect(textarea).toHaveAttribute("android-set-soft-input-mode", "nothing");
666
+ expect(textareaRoot.querySelector("text")).toBeNull();
667
+
668
+ fireNativeEvent(textareaRoot as unknown as NodesRef, textareaRoot, "layoutchange", {
669
+ width: 200,
670
+ height: 120,
671
+ });
672
+ expect(actions.layoutChanged).toHaveBeenCalledOnce();
673
+
674
+ const exec = vi.fn();
675
+ const invoke = vi.fn(() => ({ exec }));
676
+ textareaRef.current.invoke = invoke as unknown as NodesRef["invoke"];
677
+ fireEvent.tap(textareaRef.current as unknown as Element);
678
+ expect(invoke).not.toHaveBeenCalled();
679
+
680
+ fireEvent.tap(textareaRoot);
681
+ expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ method: "focus" }));
682
+ expect(exec).toHaveBeenCalledOnce();
683
+ });
684
+
685
+ it("uses native intrinsic autoresize with the Android-only line-spacing correction", () => {
686
+ setSystemInfo({ platform: "Android" });
687
+
688
+ const { rerender } = render(
689
+ <TextField.Root defaultValue={"첫 줄\n"}>
690
+ <TextField.Textarea />
691
+ </TextField.Root>,
692
+ );
693
+
694
+ const root = getRenderedRoot();
695
+ const textarea = root.querySelector("textarea");
696
+ if (!textarea) throw new Error("Expected native textarea to exist.");
697
+
698
+ expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
699
+ expect(textarea).toHaveClass("seed-text-input__textareaAndroidAutoresize--size_large");
700
+ expect(textarea).toHaveAttribute("line-spacing", "3.2px");
701
+ expect(root.querySelector(".seed-text-input__textareaAutoresizeRoot--size_large")).toBeNull();
702
+ expect(root.querySelector(".seed-text-input__textareaRoot text")).toBeNull();
703
+
704
+ rerender(
705
+ <TextField.Root value={"첫 줄\n둘째 줄\n"}>
706
+ <TextField.Textarea />
707
+ </TextField.Root>,
708
+ );
709
+
710
+ expect(getRenderedRoot().querySelector("textarea")).toBe(textarea);
711
+ });
712
+
713
+ it("uses the Android line-spacing correction and preserves an explicit override", () => {
714
+ setSystemInfo({ platform: "Android" });
715
+
716
+ const { rerender } = render(
717
+ <TextField.Root size="medium">
718
+ <TextField.Textarea autoresize={false} />
719
+ </TextField.Root>,
720
+ );
721
+
722
+ const textarea = getRenderedRoot().querySelector("textarea");
723
+ if (!textarea) throw new Error("Expected native textarea to exist.");
724
+ expect(textarea).toHaveAttribute("line-spacing", "3.2px");
725
+ expect(getRenderedRoot().querySelector(".seed-text-input__textareaRoot")).toBeNull();
726
+
727
+ rerender(
728
+ <TextField.Root size="medium">
729
+ <TextField.Textarea autoresize={false} line-spacing="7px" />
730
+ </TextField.Root>,
731
+ );
732
+ const overriddenTextarea = getRenderedRoot().querySelector("textarea");
733
+ expect(overriddenTextarea).toBe(textarea);
734
+ expect(overriddenTextarea).toHaveAttribute("line-spacing", "7px");
735
+
736
+ rerender(
737
+ <TextField.Root size="medium">
738
+ <TextField.Textarea autoresize={false} line-spacing={0} />
739
+ </TextField.Root>,
740
+ );
741
+ const uncorrectedTextarea = getRenderedRoot().querySelector("textarea");
742
+ expect(uncorrectedTextarea).toBe(textarea);
743
+ expect(uncorrectedTextarea).toHaveAttribute("line-spacing", "0");
744
+ });
745
+
746
+ it("keeps iOS native autoresize without the Android line-spacing correction", () => {
747
+ setSystemInfo({ platform: "iOS" });
748
+
749
+ render(
750
+ <TextField.Root defaultValue={"첫 줄\n"}>
751
+ <TextField.Textarea />
752
+ </TextField.Root>,
753
+ );
754
+
755
+ const root = getRenderedRoot();
756
+ const textarea = root.querySelector("textarea");
757
+ if (!textarea) throw new Error("Expected native textarea to exist.");
758
+
759
+ expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
760
+ expect(textarea).not.toHaveClass("seed-text-input__textareaAndroidAutoresize--size_large");
761
+ expect(textarea).not.toHaveAttribute("line-spacing");
762
+ expect(
763
+ root.querySelector(".seed-text-input__textareaAutoresizeRoot--size_large"),
764
+ ).not.toBeNull();
765
+ expect(root.querySelector(".seed-text-input__textareaRoot text")).toBeNull();
766
+ });
767
+
768
+ it("does not restore the previous value after an accepted controlled textarea newline", async () => {
769
+ const textareaRef = createRef<NodesRef>();
770
+ const onValueChange = vi.fn();
771
+ const bindinput = vi.fn();
772
+
773
+ function ControlledTextarea() {
774
+ const [value, setValue] = useState("첫 줄");
775
+
776
+ return (
777
+ <TextField.Root
778
+ value={value}
779
+ onValueChange={(nextValue) => {
780
+ onValueChange(nextValue);
781
+ setValue(nextValue);
782
+ }}
783
+ >
784
+ <TextField.Textarea ref={textareaRef} bindinput={bindinput} />
785
+ </TextField.Root>
786
+ );
787
+ }
788
+
789
+ render(<ControlledTextarea />);
790
+
791
+ const root = getRenderedRoot();
792
+ const textarea = root.querySelector("textarea");
793
+ if (!textarea || !textareaRef.current) {
794
+ throw new Error("Expected native textarea to exist.");
795
+ }
796
+
797
+ expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
798
+ expect(root.querySelector(".seed-text-input__textareaRoot")).not.toBeNull();
799
+
800
+ const invoke = vi.fn(() => ({ exec: vi.fn() }));
801
+ textareaRef.current.invoke = invoke as unknown as NodesRef["invoke"];
802
+
803
+ fireNativeEvent(textareaRef.current, textarea, "input", {
804
+ value: "첫 줄\n",
805
+ selectionStart: 4,
806
+ selectionEnd: 4,
807
+ isComposing: false,
808
+ });
809
+ expect(onValueChange).toHaveBeenCalledWith("첫 줄\n");
810
+ expect(bindinput).toHaveBeenCalledOnce();
811
+ expect(invoke).not.toHaveBeenCalled();
812
+
813
+ await flushControlledReconciliation();
814
+ expect(invoke).not.toHaveBeenCalled();
815
+ });
816
+
817
+ it("uses the smaller textarea cap and restores explicit maxlength for a selection", () => {
818
+ const textareaRef = createRef<NodesRef>();
819
+ render(
820
+ <TextField.Root nativeInsertionMaxLength={10}>
821
+ <TextField.Textarea ref={textareaRef} maxlength={20} />
822
+ </TextField.Root>,
823
+ );
824
+
825
+ const textarea = getRenderedRoot().querySelector("textarea");
826
+ if (!textarea) throw new Error("Expected native textarea to exist.");
827
+
828
+ expect(textarea).toHaveAttribute("maxlength", "10");
829
+
830
+ if (!textareaRef.current) throw new Error("Expected native textarea ref to exist.");
831
+
832
+ fireNativeEvent(textareaRef.current, textarea, "selection", {
833
+ selectionStart: 0,
834
+ selectionEnd: 2,
835
+ });
836
+ expect(textarea).toHaveAttribute("maxlength", "20");
837
+
838
+ fireNativeEvent(textareaRef.current, textarea, "selection", {
839
+ selectionStart: 2,
840
+ selectionEnd: 2,
841
+ });
842
+ expect(textarea).toHaveAttribute("maxlength", "10");
843
+ });
844
+
845
+ it("disables the textarea insertion cap while native input is composing", () => {
846
+ const textareaRef = createRef<NodesRef>();
847
+ render(
848
+ <TextField.Root defaultValue="최대값" nativeInsertionMaxLength={3}>
849
+ <TextField.Textarea ref={textareaRef} />
850
+ </TextField.Root>,
851
+ );
852
+
853
+ const textarea = getRenderedRoot().querySelector("textarea");
854
+ if (!textarea) throw new Error("Expected native textarea to exist.");
855
+
856
+ expect(textarea).toHaveAttribute("maxlength", "3");
857
+
858
+ if (!textareaRef.current) throw new Error("Expected native textarea ref to exist.");
859
+
860
+ fireNativeEvent(textareaRef.current, textarea, "input", {
861
+ value: "최대값",
862
+ selectionStart: 3,
863
+ selectionEnd: 3,
864
+ isComposing: true,
865
+ });
866
+ expect(textarea).toHaveAttribute("maxlength", String(NATIVE_TEXT_MAX_LENGTH_UNLIMITED));
867
+
868
+ fireNativeEvent(textareaRef.current, textarea, "input", {
869
+ value: "최대값",
870
+ selectionStart: 3,
871
+ selectionEnd: 3,
872
+ isComposing: false,
873
+ });
874
+ expect(textarea).toHaveAttribute("maxlength", "3");
875
+ });
876
+
877
+ it("applies textarea sizing directly when autoresize is false", () => {
878
+ render(
879
+ <TextField.Root>
880
+ <TextField.Textarea autoresize={false} />
881
+ </TextField.Root>,
882
+ );
883
+
884
+ const root = getRenderedRoot();
885
+ const textarea = root.querySelector("textarea");
886
+
887
+ expect(textarea).toHaveClass("seed-text-input__value");
888
+ expect(textarea).toHaveClass("seed-text-input__textareaValue");
889
+ expect(textarea).toHaveClass("seed-text-input__textareaFixed");
890
+ expect(root.querySelector(".seed-text-input__textareaRoot")).toBeNull();
891
+ });
892
+
893
+ it("preserves the native input while the disabled state changes", () => {
894
+ const renderTextField = (disabled: boolean) => (
895
+ <TextField.Root disabled={disabled}>
896
+ <TextField.Input />
897
+ </TextField.Root>
898
+ );
899
+ const { rerender } = render(renderTextField(true));
900
+ const input = getRenderedRoot().querySelector("input");
901
+
902
+ if (!input) throw new Error("Expected native input to exist.");
903
+
904
+ expect(input).toHaveAttribute("disabled");
905
+ expect(input).toHaveClass("seed-text-input__value");
906
+ expect(input).toHaveClass("seed-text-input__value--disabled_true");
907
+
908
+ rerender(renderTextField(false));
909
+
910
+ const enabledInput = getRenderedRoot().querySelector("input");
911
+ expect(enabledInput).toBe(input);
912
+ expect(enabledInput).toHaveAttribute("disabled", "false");
913
+ expect(enabledInput).toHaveClass("seed-text-input__value");
914
+ expect(enabledInput).toHaveClass("seed-text-input__value--disabled_false");
915
+
916
+ rerender(renderTextField(true));
917
+
918
+ const disabledInput = getRenderedRoot().querySelector("input");
919
+ expect(disabledInput).toBe(input);
920
+ expect(disabledInput).toHaveAttribute("disabled");
921
+ expect(disabledInput).toHaveClass("seed-text-input__value");
922
+ expect(disabledInput).toHaveClass("seed-text-input__value--disabled_true");
923
+ });
924
+ });